# Email Watch

## Decision

An email watcher is an `EmailWatcherRecord` on `RuntimeState`, in one of two
derived MODES: a **query watch** (a Gmail search query, auto-built from a
sender or supplied raw) or a **thread watch** (`threadId` set — watch ONE
conversation; ticket systems rotate sending addresses, so the Gmail thread is
the durable unit). Email watch is modeled as **N concerns consuming the general
job concern fan-out primitive** (see [Job Concern Fan-Out](job-concern-fanout.md)):
all of an agent's watchers share ONE backing interval-driven scheduled job (one
deterministic detection pass), but each concern owns its OWN chat session
(`channelId`) and a `JobRoute` keyed by its `routeKey`, so the one detection tick
fans out into one drafting worker PER matched concern, each in its own channel. A
broad `triage` concern catches mail no targeted watcher claimed. The shared
job's **pre-run hook** is the generic `skill-script` handler (see
[Pre-LLM Job Hooks](job-pre-run-hooks.md)) routed at a bundled, in-tree detection
script (`skills/google/gmail-watch/scripts/detect.ts`); its `preRunHook.config`
carries a `watches` LIST — one
`{ watcherId, routeKey, query, account?, configDir?, sender?, objective?, threadId?, followUpAfterHours? }`
entry per ENABLED watcher (plus the broad triage watch), rebuilt on every
add/remove/enable/disable/update. The script runs HEADLESS (no agent turn, no
approval): it ITERATES the watches and, for each, polls the user's Gmail through
the existing `gws` CLI for new matching message ids, dedups them against that
watch's small caller-supplied state, applies a deterministic safety floor, and
returns the RAW metadata of each surviving new match as an untrusted item labeled
by sender — the trusted hook RUNNER fences it. The hook maps the result onto the
scheduler's typed contract as ROUTED buckets keyed by `routeKey` (only non-empty
buckets) — **zero matches across all watches → short-circuit (no model turn); one
or more → the scheduler fans out one drafting worker per non-empty bucket into
that concern's channel**, with that bucket's fenced matches injected as context.
Each woken worker reads each full message and composes/sends a reply via the
EXISTING `google-gmail` skill — the detection script reads ONLY metadata
(From/Subject/Date/snippet) and never message bodies.

The architecture is deliberately a **thin deterministic detection floor + skill-driven action**, riding the general pre-run job-hook primitive with the detection floor itself a provisioned, trusted SKILL SCRIPT (not bespoke runtime code):

- **Trigger floor (the `gmail-watch/detect` skill script via the `skill-script` hook):** poll `gws` → dedup → safety-filter → return raw matches. Zero model turns when there's no new matching mail (the run short-circuits before the model).
- **Action (reuse the skill):** the drafting turn reads the body and drafts/sends via `google-gmail`, gated by `terminal_exec`'s existing approval. The action playbook lives in the backing job's prompt (outside the runner's untrusted fence).

No native Gmail REST client, no new OAuth flow, no new token store, and no new
`gmail_send`/`gmail_draft` tools are introduced. The watcher only writes a
config record; everything side-effecting flows through the already-approval-gated
skill.

## Context

This follows the messaging-bridge ADRs ([telegram-bridge.md](telegram-bridge.md),
[discord-bridge.md](discord-bridge.md)) and the connector-reprobe maintenance
loop. The user asked for an email-watching feature with multi-account support,
sender filtering, and drafted responses, and chose to reuse the already-set-up
`gws` (`google-gmail`) skill rather than build a native Gmail client.

Each watcher is bound to a Google account by `accountEmail`, resolved through the
`google-accounts` registry (see [Multiple Tagged Google Accounts For The Workspace
Skills](google-multi-account.md)): the account's gini-managed config dir is passed
to every `gws` call for that watch as `GOOGLE_WORKSPACE_CLI_CONFIG_DIR`, so the
watch polls exactly that inbox. The data model has always been multi-account-SHAPED
(`provider` + `accountEmail` + `credentialName` per record); detection now consumes
that account dimension directly.

Reference systems shaped the design: OpenClaw's untrusted-content wrapping (borrowed),
Hermes's layered inbound-safety + startup watermark seeding (borrowed), and the
"separate email-as-connector from watching-as-scheduler" lesson from Claude Code.
OpenClaw's Pub/Sub-push infrastructure and Hermes's auto-send + plaintext creds
were rejected as the wrong shape for a local-first, consent-gated runtime.

## Required Now

- `EmailWatcherRecord` carries `{ id, instance, agentId?, provider:"gmail", accountEmail?, credentialName?, query, sender?, objective?, threadId?, followUpAfterHours?, labelIds?, chatSessionId?, channelId?, persona?, toolsets?, jobId?, enabled, status:"ok"|"error"|"needs_auth", lastError?, lastPolledAt?, createdAt, updatedAt }`. `sender` is the explicitly watched address (set only when it drove the query); `objective` is the user's standing goal for the watch (validated: trimmed, capped at 2000 chars); `threadId` makes it a thread watch (authoritative for detection — `query` holds a `thread:<id>` display label); `followUpAfterHours` (thread watches only, positive) enables the silence nudge. All optional — legacy records keep working. ALL of an agent's watchers share the SAME `jobId` (the one shared backing job); `channelId` is the concern's OWN per-concern channel (the fan-out worker for this concern's bucket dispatches into it), with `persona`/`toolsets` optionally shaping that worker's `JobRoute`. `chatSessionId` is the shared "Email watch" session, kept as the route's FALLBACK for a legacy/unmigrated watcher with no `channelId` yet. The per-watch detection cursor/dedup are NOT on this record (they live on the shared job, keyed by `routeKey`). State CRUD lives in `src/state/email-watchers.ts`; all writes go through `mutateState`, never the JSON file directly.
- Detection state lives on the shared `JobRecord.hookState` (job-owned; see [Pre-LLM Job Hooks](job-pre-run-hooks.md)), NOT in a separate table, keyed PER ROUTE at the TOP LEVEL: `hookState[routeKey] = { cursor, seen, status, lastError? }` (`routeKey` defaults to the watcher id; the broad concern uses `"triage"`). Keying each route at the top level is the handler-state contract the fan-out primitive's per-bucket commit requires (see [Job Concern Fan-Out](job-concern-fanout.md)) — so each concern's cursor is partitioned and committed independently. The read path bridges the prior nested shape: the detection script and the derive-on-read health both read `hookState[routeKey]` and fall back to `hookState.byWatcher?.[routeKey]`, so an in-flight install's cursors + health survive the format flip and the first new tick rewrites the flat shape. The script is a PURE function `{watches, state} -> {result, newState}`; the scheduler persists `newState` at the at-least-once commit boundary. There is no `email_seen` SQLite table — the `after:<sec>` watermark keeps each watch's `seen` set bounded to one second's worth of boundary ids, so a small per-route in-state set replaces the per-message table. A removed watcher's stale state entry is harmless (the script reads only entries for the current `watches`) and is dropped when the shared job is eventually removed.
- The shared backing job is ONE interval-driven scheduled job (60s) per agent, bound to the shared "Email watch" chat session, with a `preRunHook = { handlerId: "skill-script", config: { skill: "gmail-watch", script: "detect", watches: [{ watcherId, query, account?, configDir? }, ...] } }` (the rebuild resolves each watcher's `accountEmail` to its config dir at build time, so the headless detector needs no registry access). The single scheduler (`runDueJobs`, the 1s self-rescheduling loop) is the only driver — there is no dedicated gmail loop and no `GINI_GMAIL_POLL_MS` knob; the cadence is the job's `intervalSeconds`. The in-flight hook + its drafting turn run inside `runDueJobs`, already covered by the existing SIGTERM scheduler drain. Adding the FIRST watcher provisions the shared job + session; adding more reuses them (idempotently identified by a stable per-agent marker — a `skill-script` hook routed at `gmail-watch` owned by the same agent) and rebuilds the `watches` list AND the `JobRecord.routes` table (one `JobRoute` per concern + the triage route), so the fan-out scheduler has a session/persona/toolset mapping per `routeKey`. Removing a watcher rebuilds both; removing the LAST enabled watcher tears the shared job + session + triage channel down (recreated on the next add/enable). Disable/enable rebuild both (the job watches only ENABLED watchers); the `watches` list and `routes` table are rebuilt on every add/remove/enable/disable. A startup `backfillEmailWatcherJobs` ensures each agent with enabled watchers has its ONE shared job (adopting an existing one by marker, or provisioning if missing — e.g. legacy per-sender watchers from before the consolidation) and re-stamps every enabled watcher's `jobId`/`chatSessionId` onto it (idempotent self-heal, never a duplicate).
- **Per-account watching.** Each watch carries its account's `configDir` (resolved at rebuild from the watcher's `accountEmail` against the `google-accounts` registry — see [Multiple Tagged Google Accounts For The Workspace Skills](google-multi-account.md)); every `gws` call for that watch runs with `GOOGLE_WORKSPACE_CLI_CONFIG_DIR=<configDir>`, so co-located watches on different accounts each poll the right inbox. A watch whose `accountEmail` is unset binds to the single registered+signed-in account (or the default gws dir when none is registered); an `accountEmail` that names no registered account falls back to the default gws session with a visible warning rather than silently watching the wrong mailbox.
- Before polling, the `detect` script checks `gws auth status` ONCE PER DISTINCT account `configDir` (resolved + cached for the tick, so several watches on one account share one auth probe and one self-address lookup). When an account is signed out, the watches bound to it short-circuit with `status: "needs_auth"` in their per-watch state, each prior `cursor`/`seen` UNCHANGED (no spam, NO model turn); the shared job stays active and watches on a different, signed-in account still poll. Signed-out is NEVER a non-zero script exit / hook `error`, which would fail the job and stop scheduling.
- The script spawns `gws` exactly as `gws-session.ts` does: `zsh -lc`, `stdin: "ignore"`, kill-on-timeout, inheriting the env the skill-script runner provides (`PATH`/`HOME` for the login shell + `GINI_*`), plus `GOOGLE_WORKSPACE_CLI_CONFIG_DIR=<configDir>` when the watch targets a specific account. `gws` reads its own client config + token from that config dir (default `~/.config/gws/`), so selecting an account is a config-dir PATH, not an injected secret. stdout AND stderr are drained CONCURRENTLY (an unread piped stream can fill its ~64KB OS buffer and deadlock the child until the kill timer fires). The gws subprocess boundary is injectable so the unit test stubs it. `gws` prints a `Using keyring backend:` preamble to STDERR before its JSON, so stdout begins at the first `{`; the parser still defensively skips to the first `{`.
- Detection uses `gws gmail users messages list --params '{"userId":"me","q":"<query>","maxResults":N}' --format json --page-all --page-limit P`. Gmail lists newest-first, so for a steady-state window that fits within the page cap the script enumerates the WHOLE matching window across pages (NDJSON, one object per page) and drains it oldest-first rather than the newest page only — otherwise older matches that don't fit on the first page would fall below the advancing watermark. When the page cap IS hit the window isn't fully enumerated, and the seeding/truncated-window handling below baselines past the un-listed tail rather than draining it. Once a watcher has a watermark the query is bounded with `after:<epochSec>` derived from the state cursor, so steady-state polling lists almost nothing.
- Metadata for the safety floor + match item comes from `gws gmail users messages get --params '{"userId":"me","id":"<id>","format":"metadata","metadataHeaders":["From","Subject","Date","To","Message-ID"]}'`. `Message-ID` (the RFC 5322 header, STABLE across inboxes) and `To` ride the item only for cross-account dedup (below) and are never emitted in the match payload. Match payloads include the containing `threadId` when known, so the drafting turn can read the full conversation.
- **Detection provides the body.** For a SURVIVING matched item only (never during seeding/short-circuit/silent ticks), the script fetches the message via `format=full`, extracts a readable text body (best-effort `text/plain`/`text/html` walk, capped to a char limit), and rides it back on the item as untrusted content. The drafting worker therefore composes from the email's actual content rather than depending on a fragile re-fetch of its own. The body is just another `untrusted:true` field the RUNNER fences with the existing JSON+nonce hardening — no new trust surface (see Trust Boundary). A body-fetch fault degrades to the snippet; the worker can still read the full thread through the `google-gmail` skill.
- **Thread watches** detect via ONE metadata-level `gws gmail users threads get` call (same `format=metadata` + `metadataHeaders` shape, rooted at the `threads` resource): a NEW match is a message with `internalDate` past the cursor, id not in `seen`, and parsed From ≠ self. Seeding baselines at the newest thread message and, mirroring the query regime, DRAFTS that one message when it is a pending counterparty reply (From ≠ self) — so a watch added to a thread with an unanswered reply drafts immediately — but stays silent when the newest is the user's own message (the ball is in their court); older thread messages are never drafted as a backlog. `seen` holds the ids at the cursor's exact millisecond. Same per-watch state shape, purity, and at-least-once commit timing as query watches.
- **Objective injection**: when a watch with an `objective` produces ≥1 surviving match (or a follow-up nudge) in a tick, the script emits ONE additional **TRUSTED** item (`untrusted:false`) — `Objective for this watch (<sender or thread label>): <objective>`. The objective came through the validated tool/API channel (the same trust level as the job prompt) and the script is trusted in-tree code, so the runner renders it UNFENCED; it must never ride inside the untrusted fence, and untrusted email content never flows into it. The principle: explicitly-stated intent is CONFIG, never routed through fuzzy memory recall.
- **Follow-up on silence** (thread watches with `followUpAfterHours`): on a tick with no new matches where the thread's last message is the user's own and older than the threshold, the script emits a TRUSTED nudge item ("No reply on this watched thread since <ISO> … draft a polite follow-up") plus the objective item, waking a drafting turn. Exactly once per outbound message: `lastNudgedForMessageId` in the per-watch state pins the nudged id; a fresh reply or a newer self-message changes the last-message id and resets the cycle. The timer is the existing 60s polling hook — no new mechanism.
- Safety floor (deterministic, drop-at-trigger): drop automated senders (From matches `no-reply` / `noreply` / `mailer-daemon` / `postmaster` / `bounce` / `notifications@`) and drop self (the address parsed out of the From header equals the signed-in address from `gws gmail users getProfile`, compared by EQUALITY — not substring, which would false-drop a human whose address merely contains self's). The automated-sender heuristic is BYPASSED when the parsed From address exactly equals the watch's explicitly watched `sender` (the user asked for that address by name — watching `noreply@ups.com` must fire) and does not apply at all in thread mode (ticket-bot replies are exactly what's watched); self-drop is mandatory in every mode and checked first. Dropping is deterministic, so re-evaluating a dropped message on a retry is harmless and needs no separate dedup.
- On a surviving match the script emits the RAW metadata (`{from,subject,date,id,snippet}` as a single JSON string, prefixed with a `New email from <sender>` label so the woken worker can attribute each match) as an `untrusted:true` item into that watch's `routeKey` BUCKET — it does NOT fence it. The hook returns the buckets as a ROUTED `context`; the RUNNER fences each item (below). The scheduler then FANS OUT — one drafting worker per non-empty bucket, into that concern's channel (`routes[routeKey].chatSessionId`), each bucket bounded by `MAX_MESSAGES_PER_TICK` (see [Job Concern Fan-Out](job-concern-fanout.md)). An idle concern (empty bucket) spawns nothing. The trusted action playbook (draft a reply PER matched email each labeled by sender, `read_skill google-gmail`, read the FULL THREAD the message belongs to, treat an injected Objective as authoritative, post a PROPOSED reply in this chat, do NOT send unless asked, surface missing facts/decisions with a "⏸ Needs your input" chat message instead of inventing them, draft follow-ups for silence nudges, emit exactly `[SILENT]` when nothing is actionable) lives in the route's prompt, OUTSIDE the untrusted fence. When the script returns zero matches across all watches the run short-circuits with no model turn at all.
- **Cross-watch precedence (targeted beats triage).** Detection dedups across watches by message id with precedence: targeted watches (those with a `sender` or `threadId`) sort first and CLAIM the message ids they match; the broad `triage` watch then DROPS any id a targeted watch already claimed, so an email matching both a targeted concern and triage routes to the targeted concern ONLY — never a double-draft. A triage watch whose every listed id was claimed opens no bucket (and thus spawns no triage worker). Per-watch newness/dedup (the cursor + boundary `seen` set) is unchanged; precedence is a deterministic claim layer on top.
- **Cross-ACCOUNT dedup (same email, two watched inboxes).** A message delivered to two watched accounts is a DIFFERENT Gmail id in each inbox but the SAME RFC `Message-ID`. Detection dedups across watches by that stable `Message-ID` so the email drafts only once: the watch whose account is the actual `To` recipient WINS and keeps the match; the other account's copy is dropped. (Gmail-id precedence above still runs within an account; `Message-ID` precedence layers across accounts.) This is why each match carries its `To` + `Message-ID` headers — used only for the claim, never surfaced to the worker.
- Each watch is iterated in its OWN try/catch: one sender's `gws`/transport fault marks ONLY that watch's `status: "error"` (cursor/seen unchanged) and the OTHER watches still poll and can still draft. A per-watch TRUNCATED-window backlog notice is a non-silent shortCircuit summary; the per-watch notices are joined into one summary when no watch produced a draftable match.
- The matched metadata is fenced by the RUNNER (reviewed core code), not the script — the prompt-injection boundary is one place, not per-handler (see [Pre-LLM Job Hooks](job-pre-run-hooks.md)). The PRIMARY breakout defense is the JSON encoding: the runner emits each untrusted item's text as a single JSON string, so quotes/newlines/markers are escaped and the whole payload stays on one physical line and cannot break the container even if a sentinel-like substring survives. Belt-and-suspenders: the payload is stripped of the fence sentinel (the strip loops to a fixpoint, so a nested rejoin cannot re-form a sentinel) and has CR/LF collapsed, the close delimiter carries a per-item nonce (derived deterministically from the text) so the close token can't be guessed and forged, and the payload is truncated to the char cap BEFORE the close marker is appended so an oversized payload can't push the marker out.
- **Seed-and-draft (first tick).** Startup seeding BASELINES, it does not enumerate. The `after:` watermark only ever moves FORWARD (toward newer mail), so an older un-listed tail can never be reached by advancing it — a seeding path that depends on enumerating the whole backlog would be wrong on a large inbox (gws lists only the newest page-cap worth). On a watcher with no cursor the first fire takes the NEWEST listed id, sets the cursor at its `internalDate`, and records just that one boundary id (plus any sibling sharing its exact epoch second, since `after:` is inclusive of the boundary second) in `seen`. Then — instead of silently baselining past it — it DRAFTS that single newest message when it is a PENDING inbound (From ≠ self, surviving the safety floor): a watch created on a conversation with an unanswered message replies to it on the first tick. The newest being the user's own message (or an automated/dropped one) seeds SILENTLY. Only the single newest qualifying message is ever drafted on seed — never the backlog: every message older than the baseline is excluded by `after:` forever, regardless of inbox size, with no pagination dependence on first run. (When the inbox had no matches at all, the cursor baselines at `now`.)
- Steady-state draining of a fully-enumerated (non-truncated) window is OLDEST-FIRST. Each fire enumerates the window, skips ids in the boundary `seen` set, drains the rest oldest-first, caps the matches at `MAX_MESSAGES_PER_TICK = 25`, advances the cursor ONCE to the LAST CONSUMED item's `internalDate`, and recomputes `seen` to the ids sharing that new cursor's exact second — forward progress that drains a >cap backlog over successive ticks without ever stepping past an un-consumed match.
- A TRUNCATED steady-state window (more genuinely-new matches than the page cap of `WINDOW_PAGE_LIMIT * WINDOW_PAGE_SIZE` since the cursor — only reachable after downtime + high volume) is a **bounded baseline-with-notice, not an eventual drain**. Because the older tail isn't listed and the forward-only watermark can never reach it, the script does NOT silently advance past the tail and does NOT storm a draft per message: it jumps the cursor to the NEWEST listed match (falling back to `now` if its metadata-get is bad, so the cursor always advances) and returns a `shortCircuit` whose NON-silent `summary` tells the user a large backlog accumulated and that it isn't drafting replies to all of them (it can triage on request). Because a shortCircuit's state persists IMMEDIATELY, the advanced cursor excludes the backlog next tick — at most one notice per episode. Zero overflow drafts, no re-loop.
- Delivery is **exactly once on the happy path, at-least-once across the delivery boundary, PER CONCERN**, owned by the job-state commit TIMING (not a per-match thunk): the `detect` script returns the advanced cursors + boundary `seen` sets keyed by `routeKey` at the top level on a `context` result, and the scheduler commits them PER BUCKET (see [Job Concern Fan-Out](job-concern-fanout.md)) — it adopts every fresh route slice and rolls back ONLY a routeKey whose non-empty bucket FAILED to dispatch its worker. So a concern whose worker dispatch fails re-detects its mail next tick while its siblings stay committed, and a concern that deterministically advanced its cursor without drafting (a seeding baseline, a self-message, a triage claimed-only tick) still commits its progress. At-least-once holds per concern — a re-detect / re-drop on a dispatch failure is idempotent (no loss, no spurious draft). ShortCircuit results (all-empty: seeding baselines, no-match, dropped-only, the truncated backlog notices) persist `newState` immediately, since there is nothing to deliver.
- A gws/transport failure for one watch is reported in THAT watch's per-watch state as `status: "error"` + a SCRUBBED `lastError`, with its `cursor`/`seen` UNCHANGED — never a non-zero exit, because the `skill-script` handler turns a non-zero exit into a throw that the runner classes TRANSIENT and a config error would flip `job.status="failed"`. The shared job keeps scheduling, the other watches keep polling, and the next healthy tick re-detects that watch from its unchanged cursor (a clean tick emits `status: "ok"` and clears `lastError`). The scrub redacts credential-bearing `.json`/`.enc` files and any home-rooted (`/Users/…`, `/home/…`, `/root`) path so a leaked diagnostic can't expose the filesystem layout.
- **Dead-watch visibility.** `gws` returns some failures as an ERROR BODY at exit 0 rather than a non-zero exit — e.g. a `threads get` against a removed/inaccessible `threadId` yields `{"error":{"code":404,…}}`, and a `messages list`/`get` can do the same. Detection inspects each response for that error body and routes it through the SAME fault path (`status: "error"` + scrubbed `lastError`, cursor/seen unchanged) instead of mistaking it for an empty window and reporting a healthy `ok`. So a watch pointed at a stale id surfaces as `error` on `listEmailWatchers`/`getEmailWatcher` rather than going silently dead.
- **Watcher health is surfaced via job-owned state, derived on read, PER WATCH.** The generic `skill-script` handler can't write the watcher record, so the detection script records each watch's last-tick health (`status` + scrubbed `lastError`) in its top-level `hookState[routeKey]` slice, which the scheduler persists onto the shared `JobRecord.hookState` each tick (a `shortCircuit` persists immediately, so the signed-out/error/healthy status lands every tick). The email read path then DERIVES the displayed status: `listEmailWatchers` / `getEmailWatcher` (the canonical path the `email_watch` tool, `GET /api/email/watchers`, and `gini email list` use) look up the shared job by `watcher.jobId` and overlay `hookState[watcher.id].status` / `.lastError` (falling back to the legacy `hookState.byWatcher[watcher.id]` for an install mid-format-flip) onto the returned record — so one sender's `needs_auth`/`error` surfaces in isolation from healthy siblings. `EmailWatcherRecord.status` / `lastError` are thus derived-on-read; `enabled` stays the separate lifecycle flag, and a watcher with no per-watcher hookState entry (legacy / pre-first-tick) keeps its stored status. This is email-domain code only — `src/hooks` and the generic `skill-script` handler are untouched.
- The `email_watch` agent tool (toolset `email`, action enum `add | list | remove | disable | enable | update`) is always-on and low-risk — it only writes a watcher record (and, on `add`, a dedicated chat session). `disable` / `enable` pause/resume a watcher (and its backing job) without deleting it; `update` revises a watcher's `objective` (the user changed the goal mid-negotiation), or clears it with `clearObjective: true`. The tool description steers the model to DISTILL a stated goal into `objective` at creation and to prefer `threadId` for support tickets / ongoing conversations. One tool with an action enum keeps the catalog surface minimal, mirroring the `create_job` / `list_jobs` / `delete_job` precedent collapsed into one.
- **Account selection at `add`.** `add` accepts an optional `account` (the watched `accountEmail`). With exactly one connected account it auto-defaults; with an explicit `account` it resolves directly; but with 2+ connected accounts and NO `account` given, `add` REFUSES to default — it returns a hint listing the connected accounts and instructs the model to ask the user which inbox to watch via the `ask_user` choice primitive (see [User Choice Prompt (ask_user / chat.choice)](user-choice-prompt.md)), then re-add with the chosen account. No watcher is minted on that ambiguous turn. (The selection policy mirrors the per-command Workspace-account rule — see [Multiple Tagged Google Accounts For The Workspace Skills](google-multi-account.md) — applied to a watch's standing account binding.)
- **Whole-inbox triage opt-in (`triage`).** `add` also accepts `triage: true` / `false`, which provisions / tears down the broad respond-or-flag concern over the entire inbox (below). It is OFF by default and set ONLY on an explicit "triage all my mail" request — a targeted sender/thread watch never turns it on.
- `add` builds the query: a raw `query` wins; a thread watch gets a `thread:<threadId>` display label; else `from:<sender>`; else `in:inbox`. The auto-built shapes carry NO `is:unread` — the `after:` watermark + boundary `seen` set already define newness, and `is:unread` loses mail the user reads on another device before the poll tick (read-elsewhere race); the no-sender default is `in:inbox`, never an empty `q` (which lists sent/spam/trash). The startup backfill heals stored queries that EXACTLY match the retired auto-built shapes (`from:X is:unread` → `from:X`; bare `is:unread` → `in:inbox`) and never touches other queries (a raw user query may include `is:unread` on purpose). The heal runs exactly ONCE, gated by the `RuntimeState.emailWatcherQueryHealedAt` marker stamped in the same write — so after the first upgrade boot a user-created raw query that happens to match a retired shape is never rewritten on a later restart. `addEmailWatcher` ensures the agent's shared job + session exist (reused if present, created on the first watcher), provisions this concern's OWN per-concern channel (a `channel`-kind session stamped with the `feature:"email-watch"` marker, titled distinctly and deterministically from the watcher identity — `Email: <sender>` for a sender watch, `Email thread: <threadId>` for a thread watch — so the channel is navigable; the marker, not the title, is the identity key cleanup uses), creates the watcher record pointing at its `channelId` + the shared `jobId`/`chatSessionId`, then rebuilds the shared job's `watches` list AND `routes` table from the enabled watchers; if provisioning fails the watcher is rolled back so no orphan watcher lingers. A duplicate `add` (same owning agent + same `threadId`, or same `sender` with no thread) returns the EXISTING enabled watcher instead of minting a duplicate channel/route — the idempotency guard that lets triage auto-escalation re-call `add` safely. `remove` deletes the watcher row, drops its route, reclaims its now-unreferenced per-concern channel via the identity-based orphan sweep, then rebuilds the `watches` list — the shared job + session survive while sibling watchers remain enabled, and are torn down only when the LAST enabled watcher is removed (`deleteChatSession` cascades the shared session's messages/blocks/identity snapshot, guarded if already gone). The tool, `POST /api/email/watchers`, and `gini email add` all route through the shared `addEmailWatcher` helper so they produce identical records.
- `disable` / `enable` route through `setEmailWatcherEnabled`, which flips `EmailWatcherRecord.enabled` then rebuilds the shared job's `watches` list + `routes` table (the job watches only ENABLED watchers) so a disabled watcher stops being polled without removing it. A disabled watcher's route is dropped but its per-concern channel is KEPT (still referenced), so re-enable reuses the same channel rather than minting a fresh one. Disabling the LAST enabled watcher tears the shared job + session down; re-enabling recreates them and re-stamps the record's `jobId`/`chatSessionId`.
- **The triage concern is OPT-IN.** Whole-inbox triage is OFF by default — a targeted sender/thread watch NEVER provisions it. An agent opts in explicitly (the `email_watch` tool's / API's `triage: true`), and only then does the rebuild append a synthetic broad concern: a `buildTriageWatch()` watch with a broad `in:inbox` query and the fixed `routeKey: "triage"` (no `sender`/`threadId`, so cross-watch precedence treats it as the non-targeted remainder), plus a `JobRoute` whose worker is a CONSTRAINED subagent — `systemPrompt = EMAIL_WATCH_TRIAGE_PROMPT` (a respond-or-flag playbook: draft a PROPOSED reply when confident, post "⏸ Needs your input" when a fact/decision is missing, escalate a coherent ongoing thread via `email_watch action:add` with a distilled objective, `[SILENT]` when nothing is worth surfacing) and a `toolsets: ["email","terminal"]` whitelist (`email` owns `email_watch` for escalation; `terminal` owns the `gws` CLI; `read_skill` + the `google-gmail` skill ride in unconstrained; `messaging` is deliberately excluded). The triage worker runs in its OWN channel (`"Inbox triage"`, found by the feature marker + that title; provisioned once per agent on opt-in, torn down on opt-out or with the last watcher). When the triage worker calls `email_watch action:add` to mint a concern, it reaches the SAME `addEmailWatcher` path (the duplicate guard makes a re-add idempotent), so the next rebuild wires the new concern's channel + route — intelligent escalation composing on the fan-out primitive, no new mechanism (see [Job Concern Fan-Out](job-concern-fanout.md)). Zero-idle-turn holds: the triage worker spawns only when detect routed unclaimed mail into the `"triage"` bucket; an empty or fully-claimed inbox opens no bucket.
- **Per-concern-channel migration.** A run-once `migrateWatchersToPerConcernChannels` (gated by the `RuntimeState.emailWatcherChannelsMigratedAt` marker, stamped in the same write — mirroring `emailWatcherQueryHealedAt`) moves an install off the single shared "Email watch" channel onto the per-concern model: in `backfillEmailWatcherJobs`, each enabled watcher with no `channelId` gets its own distinctly-titled per-concern channel, and the per-agent rebuild then points that watcher's route at it. SAFEST PATH: the migration only ADDS channels — it never moves a cursor (the per-route `hookState` is untouched) and never deletes the shared session while a route can still fall back to it, so no draft is ever lost; an unmigrated watcher keeps routing to the shared session until it gets a channel. Idempotent: a re-run after the marker is set returns early.
- HTTP: `GET /api/email/watchers` (list), `POST /api/email/watchers` (add; accepts `sender`/`query`/`account`/`objective`/`threadId`/`followUpAfterHours`, or `triage` alone to flip whole-inbox triage), `PATCH /api/email/watchers/:id` (`{ enabled? }` and/or `{ objective? }`, where an explicit `objective: null` CLEARS it — distinct from omitted = unchanged and `""` = rejected), `DELETE /api/email/watchers/:id` (remove) — thin handlers over the state module. CLI: `gini email list | add --from <sender> [--query <q>] [--objective <goal>] | remove <id> | disable <id> | enable <id>`. Deep validation (objective trim/cap, threadId restricted to the opaque `[A-Za-z0-9_-]` token charset, followUpAfterHours positive + thread-only) lives in the shared state helpers so every channel enforces the same contract. `query`/`sender` are NOT charset-restricted (they legitimately carry spaces/`:`/`@`/`.`); every gws CLI param is shell-escaped at the single `jsonParam` serialization choke point so no field can break out of the `zsh -lc` quoting.

## Trust Boundary

- Inbound email is untrusted external content. The detection script emits RAW matched metadata AND, for a surviving match, the fetched message body as `untrusted:true` items; the trusted hook RUNNER (reviewed core code) fences each one with the JSON+nonce+fixpoint hardening before it reaches the model, so a hostile subject/snippet/body cannot smuggle instructions and the script never has to get the fence right (see [Pre-LLM Job Hooks](job-pre-run-hooks.md)). The body the script fetches rides the SAME untrusted fence as the metadata — it adds NO new trust surface, only spares the drafting worker a fragile re-fetch; the full thread is still read inside the drafting turn through the consent-gated `google-gmail` skill. The script runs HEADLESS, outside the spawned task's approval/audit/trace envelope, and is PURE — it writes no state itself (the cursor rides back on its result for the scheduler to persist).
- Drafting a reply is surfaced as a CHAT MESSAGE in the watcher's session for the user to review; the agent does not auto-send. Sending requires an explicit user instruction, which then runs `gws gmail +reply` through `terminal_exec`'s approval gate — no new send tool or risk plumbing is added.
- The script shells `gws` with fixed query strings and integer `after:` values it builds itself; raw email bytes never reach the shell. `gws` reads its own client config + token from its config dir — `~/.config/gws/` or the account's gini-managed dir selected via `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` — so account selection is a PATH, not a secret: no credentials are injected into the spawn env and none land on the watcher record (it stores only the `accountEmail`, never a token). The hook config carries only declarative data (the query + the config dir + the skill/script routing keys), never executable code — the detection logic is the in-tree, reviewed `gmail-watch/detect` script.

## Limitations / Open Questions

- **Account binding.** A watcher binds to one account by `accountEmail`, resolved to that account's config dir through the `google-accounts` registry. A watch whose `accountEmail` names no registered account falls back to the default gws session with a visible warning rather than failing — re-registering the account or re-adding with the correct `account` heals it.
- **`after:`-second cursor precision.** Gmail's `after:` is second-granular while the watermark is millisecond `internalDate`. The boundary `seen` set (the ids sharing the cursor's exact second, carried in the job state) makes the second-rounding harmless: a re-listed boundary message is skipped. The set is bounded to one second's worth of mail by the watermark.
- **History robustness.** v1 uses `messages.list` + a per-watcher watermark rather than `history.list`, so there's no `historyId`-404 full-resync path to maintain. If a future phase moves to `history.list` for lower latency/cost, it must add the bounded full-resync on a 404 history id.
- **Drafts in Gmail.** v1 posts the proposed reply as a chat message, not a Gmail draft. Landing drafts in the user's real Gmail Drafts (threaded) is a future option.
- **Pub/Sub-pull low-latency.** Polling at ~60s is the v1 cadence. A future opt-in could use Pub/Sub-pull (outbound only, no public webhook) for lower latency without changing the trust model.

## Verification

- `bun test skills/google/gmail-watch/scripts/detect.test.ts` exercises the stateless detection engine with an injected `gwsSpawn` (state in, state out, no store): the parse helpers (the `gws` preamble + multi-page NDJSON window, auth-status, metadata), the error scrub (`.json`/`.enc` files, home-rooted + `/root` paths, no over-redaction of `/rootcause`), the safety floor (automated + self by address equality, no false-drop of a human whose address contains self's; the explicit-sender bypass with self still dropped), the trusted objective item (emitted only on matched/nudged ticks, never fenced), the body fetch (a surviving match carries the `format=full`-extracted body — `text/plain`, tag-stripped `text/html`, truncated-with-marker when long; a fetch error falls back to the snippet; a silent seed / dropped-only tick fetches NO body), thread mode (metadata-level `threads get` shape, seed-and-draft the newest counterparty message, silent self-newest seed, new-message diff, no automated filter, at-least-once, the 404 error body reporting `error` on both a steady and a seeding tick with the scrub, a genuine empty thread still seeding ok) and the follow-up nudge (threshold + last-sender predicates, once per outbound message, reset on a newer self-message), the sender-labeled (un-fenced) match item, the single-watch regimes — seed-and-draft the single newest pending inbound (silent when the newest is self/automated, never the older backlog, same-second siblings recorded), dedup at the boundary second, the oldest-first backlog drain + cap (cursor advances to the last consumed item, the remainder drains on later fires), the truncated-window backlog notice (a non-silent shortCircuit, cursor jumps to newest, advances even on a bad newest metadata-get, no re-loop), a list error body (exit 0) reporting `status:error` with cursor/seen unchanged, the `after:<epochSec>` watermark bound (present once a cursor exists, absent on seeding), a clean tick stamping `status: "ok"`, and at-least-once (re-running with the old state re-detects) — plus the `run()` wrapper's health mapping (signed-out → `needs_auth`, gws-error → `error` + scrubbed `lastError`, both with cursor/seen unchanged; a healthy tick → `ok`) AND the multi-watch `runWatches` orchestration (per-watch state keyed by `watcherId`, per-watch error isolation — one sender's gws fault marks only that watch and the others still draft, signed-out marks every watch `needs_auth`, matches across senders aggregate into ONE context turn each labeled by sender, per-watch backlog notices join into one summary, a watch with a `configDir` runs EVERY gws call under that account's dir while a watch without one uses the default, the same `Message-ID` across two accounts drafts exactly once with the To-recipient account winning, distinct `Message-ID`s are NOT deduped, and within-account targeted-vs-triage gmail-id dedup still holds).
- `bun test src/capabilities/skill-script-hook.test.ts` covers the generic `skill-script` handler that runs the detect script: the stdout → `HookResult` mapping, the state round-trip, and the error taxonomy (missing skill/script → config error; non-zero exit → transient throw).
- `bun test src/hooks` covers the general pre-run hook primitive and its module boundary, including the runner's untrusted fence + the job-owned `hookState` commit timing (see [Pre-LLM Job Hooks](job-pre-run-hooks.md)). `bun test src/state` covers the `EmailWatcherRecord` CRUD + the SHARED backing-job lifecycle (first add provisions one shared job + session; a second add reuses them and appends to `watches`; removing one of several rebuilds `watches` but keeps the shared job; removing/disabling the LAST watcher tears it down; re-enable recreates it; backfill provisions ONE shared job per agent, heals the retired query shapes exactly, and is idempotent), the per-concern channels + fan-out routes (add provisions a distinctly-titled per-concern channel + a route targeting it; a per-concern title survives a backfill that runs the legacy rename-heal; a persona watcher layers its `systemPrompt` + passes its toolsets through; remove drops the route + reclaims the channel while a sibling survives; disable drops the route but keeps the channel for re-enable; the duplicate-add guard returns the existing watcher with no new channel/route; the per-concern-channel migration backfills once + is idempotent; an unmigrated watcher falls back to the shared session), the account dimension (`resolveWatchAccount`: an unset `accountEmail` binds to the single registered+signed-in account or the default gws dir, a set one resolves to its config dir case-insensitively, an unregistered one yields no config dir + a visible warning; `accountSelectionHint` asks the user only when no account is given AND 2+ accounts are signed in, auto-defaults for one, stays quiet for an explicit account), the OPT-IN triage concern + intelligent router (a sender/thread watch alone provisions NO triage; `triage:true` provisions a single broad `in:inbox` `"triage"` watch + channel + route, `triage:false` tears it down, opt-in is idempotent, backfill never auto-creates triage for an install that never opted in; the constrained respond-or-flag route with the `["email","terminal"]` whitelist; an `email_watch` add from a triage context mints a concern + route), the new-field plumbing (sender persisted only when it drove the query; thread adds store `threadId` + the label query; objective validation/update; `followUpAfterHours` rejected on query watches), the playbook pins (thread reading, objective, the "⏸ Needs your input" marker, follow-up, the standing safety rules), and the per-watcher derive-on-read health (a `needs_auth`/`error`/`ok` entry in the top-level `hookState[routeKey]` surfaces on `listEmailWatchers`/`getEmailWatcher` with the scrubbed `lastError`, isolated from a healthy sibling; the legacy `byWatcher` shape still resolves via the fallback; a healthy tick clears a prior error; a watcher with no per-watcher entry keeps its stored status).
