---
title: Drip (queue-and-trickle inbox feeder) (part 2)
---

# Drip (queue-and-trickle inbox feeder) (part 2)

## What it is

This is part 2 of the [Drip (queue-and-trickle inbox feeder)](drip.md) page. That page explains what a drip is, how to create and fill one, and how its cadence and repeat-forever modes behave. This half carries the rest: the internals a drip is built from — its five tables, the services that read and write them, and the CLI surface that drives the whole feature — and what happens on the inbox side once an item has been released, from the previews and thumbnails to the right-click actions and the in-place text editor.

## Where to find it

Everything described here is reached the same way as on the [main page](drip.md): the **Drips** virtual project in the Omniscio sidebar for the queue itself, its management pane for the Pending / Released / Archived / All tabs, and the **inbox viewer** that opens when you click a released item. Nothing on this half has a settings screen of its own.

## How it behaves

**Sidebar list — status dots and the collapsible Archived section.** Both the compact sidebar ([`DripSidebar`](/src/renderer/src/features/drip/DripSidebar.tsx)) and the full-page Drips view ([`DripVirtualProject`](/src/renderer/src/features/drip/DripVirtualProject.tsx)) group drips into **Active / Paused / Archived**. Each row's status dot comes from ONE shared helper, `dripStatusDotClass` ([`src/renderer/src/features/drip/drip-status-dot.ts`](/src/renderer/src/features/drip/drip-status-dot.ts)): only an `active` drip gets the live amber dot; `paused` and `archived` are inactive and render the muted grey dot. Archived is deliberately **NOT** green — the green "success" dot read as "active/healthy" on a shelved drip. That mapping had been copy-pasted into three files (sidebar, full-page view, detail header), which is exactly how the bug spread, so all three now call the one helper — keep new call sites pointed there, never re-inline the mapping. The **Archived** section is a collapsible disclosure: collapsed by default so shelved drips don't crowd the working set, and it auto-opens while a search query is active so a matching archived drip is never hidden behind a closed header (Active and Paused stay always-open). That collapse state is the shared `useArchivedDisclosure` hook ([`src/renderer/src/features/drip/use-archived-disclosure.ts`](/src/renderer/src/features/drip/use-archived-disclosure.ts)) so the two surfaces behave identically; the disclosure toggles are recorded in the raw-`<button>` shrink-only baseline as section-header affordances (the canonical `Button` would break the header look). Locked by [`drip-status-dot.test.ts`](/tests/unit/features/drip/drip-status-dot.test.ts), [`use-archived-disclosure.test.ts`](/tests/unit/features/drip/use-archived-disclosure.test.ts), and the `DripSidebar` / `DripVirtualProject` render tests.

**Needs You — the actual waiting inbox cards, in the hub.** The Drips hub shows a **Needs You** section at the very top — above Active — that renders the ACTUAL inbox card for every released drip item still waiting in your inbox (released, un-archived, un-snoozed), so you can see what your drips have delivered without opening the inbox at all. It renders on **both** hub surfaces, via the shared [`DripNeedsYouSection`](/src/renderer/src/features/drip/DripNeedsYouSection.tsx) (which also owns the Edit pencil's `CadenceModal`, so the load-or-use-cached drip fetch is written once): the full-page [`DripVirtualProject`](/src/renderer/src/features/drip/DripVirtualProject.tsx) on desktop, and — since it is the mobile landing surface — [`DripSidebar`](/src/renderer/src/features/drip/DripSidebar.tsx) on a phone. That second surface is load-bearing, not a nicety: `DashboardMobileLayout` routes a hub that has a `sidebarComponent` and no `mobilePrefersPanel` to the SIDEBAR, so while the section lived only in the panel the phone's Drip hub badged a waiting item and then showed nothing but the drip SCHEDULES — the item it counted was unreachable from the hub that counted it. In the sidebar the section is a SEPARATE child (`MobileNeedsYou`) mounted only when `isMobile`, which is what keeps its store subscription and its `loadInboxItems()` pull off the desktop path entirely — an `isMobile` branch inside `DripSidebar` would still have subscribed the rail to `inboxItems` it renders nothing from (and did, briefly, crash the existing `DripSidebar` spec's partial store mock). It renders ABOVE the sidebar's load/error/empty tree, so a drips-list failure can't hide an item the hub already badged; the desktop rail stays card-free because the panel beside it already shows them. The card's **Start session** also advances the mobile nav to the detail panel, since a phone has no second pane for the inbox viewer to appear in. Each card renders the SAME content body as the inbox detail pane via the shared [`DripInboxItemBody`](/src/renderer/src/features/drip/DripInboxItemBody.tsx) — extracted verbatim from `DripInboxViewer` so the hub card and the inbox can never diverge — plus inline **Snooze** + **Archive** (the canonical `InboxSnoozeButton` / `InboxArchiveButton`; snooze keyed on the same `payloadRef.id` the inbox uses via `buildSnoozeEntityDetail(dripInboxUnifiedItem(item))`, archive via `archiveInboxItem` so it inherits the inbox's undo), an **Edit** pencil that opens the drip's `CadenceModal`, and a **Start session** button that OPENS the item in the inbox in one click (`setActiveProject(INBOX_ID)` + `setActiveInboxDripItem`) — the single-pane `StartSessionButton` + its bare-`N` hotkey can't be replicated across a card list (the [`start-session-button-universality`](/tests/unit/lint/start-session-button-universality.test.ts) guard), so the card routes to the inbox pane where it lives. The list comes from `dripInboxLiveItems` / `useDripInboxLiveItems` ([`drip-inbox-items.ts`](/src/renderer/src/stores/drip-inbox-items.ts)) — the SAME released-un-archived-un-snoozed filter the unified inbox projects (both now share the `isDripItemLiveInInbox` predicate) — and `DripVirtualProject` calls `loadInboxItems()` on mount so the cards appear even if the inbox was never visited this session; the section hides when nothing is waiting. Card component: [`DripInboxCard`](/src/renderer/src/features/drip/DripInboxCard.tsx).

**Needs You owns the archive key.** The card list is one focusable container (`[data-drip-needs-you-nav]`): the first card is highlighted, the arrow keys move the highlight, and the user's resolved **archive** binding (E / Ctrl+W by default, so a remap is honored) archives the HIGHLIGHTED card through the very same `archiveInboxItem` store action its Archive button calls — inheriting the optimistic remove, the inbox-cursor advance and the Ctrl+Z undo from that one place. [`use-drip-needs-you-nav.ts`](/src/renderer/src/features/drip/use-drip-needs-you-nav.ts) holds the contract, mirroring the PR Merge Queue's `use-pr-card-keyboard-nav`. Why the LIST must own the key rather than the global handler: these cards are not inbox rows, so nothing on this surface ever sets the inbox-selection mutex that the global `closePanel` chain resolves its target from — a bare E therefore had nothing the user was looking at, fell through to the session default and silently did nothing (reported 2026-09-15, "I can't press E to archive this item"), leaving the card's button as the only way to archive it; and on a day when a left-over inbox selection WAS still set, the same key would instead have archived that unrelated item from here. The highlight is the point — a bare-key archive with no visible target is how the cross-context wrong-archive class recurs. The matching surface yield (`[data-drip-needs-you-nav]` → `closePanel`, in [`keyboard-shortcuts/surface-yields.ts`](/src/renderer/src/hooks/keyboard-shortcuts/surface-yields.ts)) stops the global chain firing behind the list, the way the PR queue's does. The autofocus is ONE-SHOT and never steals: it takes focus when the cards first appear so the key works with no prior click, but never re-grabs on a later drip push and leaves an already-focused field alone — and it is inert on mobile, where the same section renders inside `DripSidebar`. Locked by [`use-drip-needs-you-nav.test.tsx`](/tests/unit/features/drip/use-drip-needs-you-nav.test.tsx) and the [`drip-needs-you-card-nav-yield.test.ts`](/tests/unit/lint/drip-needs-you-card-nav-yield.test.ts) marker↔yield guard.

**Cadence label — one humanizer, never raw cron.** Each row / header shows its schedule as plain English via ONE shared helper, `dripCadenceLabel` ([`src/renderer/src/features/drip/drip-cadence-label.ts`](/src/renderer/src/features/drip/drip-cadence-label.ts)): the user's saved `cronNaturalLanguage` when non-empty, else `humanizeCronExpression(cronExpression)` (which itself falls back to "Custom schedule", never a bare cron). Exactly like the status dot above, the `cronNaturalLanguage?.trim() || cronExpression` fallback had been copy-pasted into the same three files (sidebar, full-page row, detail header) — and a drip created from a preset chip or a typed raw cron leaves `cronNaturalLanguage` null (see [`drip-types.ts`](/src/shared/drip-types.ts)), so those surfaces rendered a bare `0 12 * * *`. All three now call the one helper; never re-inline the fallback. Locked by [`drip-cadence-label.test.ts`](/tests/unit/features/drip/drip-cadence-label.test.ts) (the helper, incl. the exact bug repro) + the `DripSidebar` / `DripVirtualProject` cadence render regressions, and by [`cron-approval-display-contract.md`](/.claude/memory/contracts/cron-approval-display-contract.md) (I6). The **cadence EDITOR** ([`CadenceModal`](/src/renderer/src/features/drip/CadenceModal.tsx)) + its suggestion list ([`DripCadenceList`](/src/renderer/src/features/drip/DripCadenceList.tsx)) follow the same rule: a cron-only drip opens with the field EMPTY and a plain-English "Current: …" line (never seeding the raw cron into the input), keeps `cronExpression` byte-for-byte unless you retype the cadence, and translates a pasted cron live via `humanizeCronExpression` instead of the positional "minute · hour · day-of-month" caption — and the preset rows no longer print the raw cron beside each label. This "never a raw cron/RRULE on screen" rule is now enforced repo-wide by [`no-raw-cron-in-user-surfaces.test.ts`](/tests/unit/lint/no-raw-cron-in-user-surfaces.test.ts), which fails the build on any raw `cronExpression` / `rrule` rendered to a user outside the humanizers (contract I10–I15, extending it to the self-heal panels, cron rows/pickers, and Alarm schedule too).

The cron / NL parser is shared with Snooze — `parseSnoozeTime` in [`src/shared/snooze-time-parser.ts`](/src/shared/snooze-time-parser.ts) is wrapped by `parseDripNlCron` in [`src/shared/drip-nl-cron-parser.ts`](/src/shared/drip-nl-cron-parser.ts) so the user's natural-language input goes through the same deterministic parser everywhere.

Two of the five push channels are **critical** — `DRIP_ITEM_RELEASED` and `DRIP_ARCHIVED` are in `CRITICAL_PUSH_CHANNELS` in [`src/shared/push-event-schemas/index.ts`](/src/shared/push-event-schemas/index.ts), so their payloads are Zod-validated on the emit side via `emitPush()` and listener payloads are validated under the consumer-side flag. The three management-UI-only pushes (`DRIP_RELEASE_SKIPPED`, `DRIP_ITEM_ADDED`, `DRIP_CHANGED`) are not critical; `DRIP_ITEM_ADDED` is deliberately omitted from the mobile inbox push allowlist so a folder scanner discovering 200 new files at once doesn't wake every phone. `DRIP_CHANGED` is fired by the CRUD handlers in [`src/main/ipc/handlers-drip.ts`](/src/main/ipc/handlers-drip.ts) on every create/update/pause/resume/delete so a second Omniscio window and the phone inbox refresh instantly — its renderer listener in [`src/renderer/src/hooks/useDripPushEvents.ts`](/src/renderer/src/hooks/useDripPushEvents.ts) triggers the same debounced `handlePushUpdate` (reload-drips + reload-inbox-items) used by the scanner-side channels.

**Start session from an inbox item.** Each released item's inbox viewer offers a **Start session** button that launches a Claude Code session carrying the item as its opening prompt — a thin launch layer over the existing session-spawn machinery, no new spawn path. The opening prompt is composed by `buildDripSessionPrompt` in [`src/shared/drip-session-prompt.ts`](/src/shared/drip-session-prompt.ts): a non-blank per-drip preset leads, then a blank line, then content-type-aware context (text body, link URL + title + description, file name + on-disk path, book prose, or a drip-name fallback), so the result is never empty. The per-drip preset and default repo persist as two nullable columns on `drips` (`session_prompt`, `session_project_id`; NULL = no saved default), joined onto each inbox row by `listInboxItems` and edited in the cadence form. The launch target resolves with a fallback: the saved repo is used only if it still exists **and** is spawnable, otherwise the session opens in the Claude repo (the `__claude__` sentinel project). The repo picker is the canonical `<Select>` icon dropdown ([`src/renderer/src/components/ui/Select.tsx`](/src/renderer/src/components/ui/Select.tsx)) — each repo renders its `ProjectIcon` (letter-glyph fallback for icon-less projects), which a native `<select>` can't do; its portal listbox (`position: fixed` + `containToViewport`) can't be clipped by the scrollable dialog body. The prompt textarea honors the user's global `submitKeyMode` (plain Enter submits in `'enter'` mode via `isPlainEnterSubmit`; Ctrl/Cmd+Enter always submits). The launch `source` (`'drip-start-session'`) **must** be registered in `NON_MISSION_SESSION_SOURCES` ([`src/shared/ipc-schemas/session.ts`](/src/shared/ipc-schemas/session.ts)) or the IPC silently rejects the launch before a session is created. The agent-alert inbox carries an identical twin, `AlertStartSessionDialog` (source `'alert-start-session'`), sharing the `<Select>` repo picker + the submit-key helper. Full invariants and the tests that lock them live in [`.claude/memory/contracts/drip-start-session-contract.md`](/.claude/memory/contracts/drip-start-session-contract.md).

**Live HTML preview.** A drip `file` item whose name ends `.html`/`.htm` renders LIVE in the inbox viewer — its HTML, CSS, and **JavaScript execute** — instead of the file chip, gated by the `dripHtmlPreviewEnabled` setting (default on, under Settings → Features → Drip, beneath Enable Drip). `srcdoc`/`blob`/`data` can't be used: they inherit the renderer's `script-src 'self'` CSP and block inline JS, so the bytes are instead served under their OWN response CSP by ONE shared core, `serveDripPreview` ([`src/main/services/drip/serve-drip-preview.ts`](/src/main/services/drip/serve-drip-preview.ts)), feeding two transports that can never drift: **desktop** loads it through the `drip-preview://item/<id>` Electron scheme in a separate-process `<webview>` (a runaway script can't freeze Omniscio), **mobile** through the token-gated `/drip-preview/<id>` web-access route in a sandboxed `<iframe>` (`allow-scripts`, NEVER `allow-same-origin` → an opaque origin that can't touch the app). `getDripPreviewUrl` ([`src/renderer/src/lib/platform.ts`](/src/renderer/src/lib/platform.ts)) picks the transport; the component is [`src/renderer/src/features/inbox/InboxHtmlPreview.tsx`](/src/renderer/src/features/inbox/InboxHtmlPreview.tsx); HTML detection is `isHtmlDripFile` ([`src/shared/drip-html.ts`](/src/shared/drip-html.ts)). The served CSP is **contained** — the page's inline JS/CSS run, but `connect-src 'none'` + no remote scheme-source means it reaches NO network, so an untrusted page can't phone home; files over 5 MB fall back to the chip; the read is path-scoped via `readDripFile`. Because it's contained, a page that pulls Tailwind/Chart.js/fonts from a CDN renders unstyled — relaxing the CSP to allow specific CDNs is a one-line change in the core. **Pop it out.** The inline frame is a fixed `h-[70vh]` box sized against the VIEWPORT rather than the room left under the pane chrome, so on a phone the pane header + the "Updated …" / "Generated by …" meta + the frame fill the screen, the outer scroll container has almost no range, and that meta never scrolls away — reported 2026-08-30 as "the date and stuff is pinned". So the preview carries a pop-out control that routes by platform: on **desktop** it opens the same contained frame in a full-window portal (focus-trapped + `aria-modal`, Escape to exit — the preview is `data-readonly-preview`, so without the trap a stray `E` would archive the item behind it); in **any browser** it opens the page as a real tab, which brings pinch-zoom, find-in-page, share, and survives switching apps. The browser branch calls `window.open` directly (`openExternalUrl` would relay `window:open-external` over the web-access WS and open the tab on the DESKTOP machine), builds its URL synchronously so the open stays inside the click gesture, and uses the plain cookie-authed `${origin}/drip-preview/<id>` — no single-use `?pt=` (the tab would 401 on its first reload) and no `?token=` in the address bar. Because a top-level tab inherits no `<iframe sandbox>` attribute, the web route APPENDS `sandbox allow-scripts` to the served CSP so the page still gets an opaque origin there — a no-op for the existing iframe, and the reason the digest can never read the app's storage from a real tab. Full invariants + the tests that lock them: [`.claude/memory/contracts/drip-html-preview-contract.md`](/.claude/memory/contracts/drip-html-preview-contract.md).

**Inline media (video / audio / PDF) & openable files.** A released `file` item renders by KIND ([`dripFileRenderKind`](/src/renderer/src/features/drip/drip-detail-helpers.tsx)): a **video** or **audio** file plays inline (`<video>`/`<audio>` with a seek bar), a **PDF** renders in an inline frame, an **image** shows inline (as before), an **`.html`** file gets the live preview above, and **anything else** — plus any inline-render failure — becomes an **openable card** ([`DripFileChip`](/src/renderer/src/features/drip/DripFileChip.tsx)) offering **Open** (OS default app) + **Reveal in folder** on desktop, or a **download** link on mobile. For safety, executables / scripts (`.exe`/`.bat`/`.ps1`/…) are NEVER opened — the card offers Reveal only, and the open IPC refuses them (`isExecutableExtension`). The KIND comes from the file's EFFECTIVE MIME ([`deriveEffectiveMime`](/src/shared/drip-mime.ts)) — derived from the extension when the stored MIME is generic — so a folder-imported `.mp4` stored as `application/octet-stream` still plays as video with no re-import and no DB migration. Bytes STREAM from disk with HTTP Range (seek without pulling the whole file) through one shared core, [`serveDripMedia`](/src/main/services/drip/serve-drip-media.ts), behind two transports — the desktop `drip-media://item/<id>` Electron scheme and the token-gated mobile `/drip-media/<id>` web-access route — exactly mirroring the Live-HTML-preview one-core-two-transports shape. The `drip-media:` scheme is privileged `bypassCSP:false` (default session only) and admitted by `index.html`'s `media-src` / `frame-src`. Full invariants + the tests that lock them: [`.claude/memory/contracts/drip-media-contract.md`](/.claude/memory/contracts/drip-media-contract.md).

**Queue-row preview thumbnails.** The Drips management pane's queue rows (the **Pending** / **Released** / **All** tabs) show a small fixed-size (40 px) preview thumbnail per item instead of a bare content-type icon: an **image** item shows the picture, a **video** its first frame with a play-triangle overlay, a **link** its og: preview image, and a **PDF** its first page. This reuses the inbox viewer's transports with **nothing new on the backend** — images via the `DRIP_ITEM_FILE_READ` data URL warmed through the [drip-image prefetch cache](/.claude/memory/contracts/drip-image-prefetch-contract.md), video first-frames via the `drip-media://` stream, link images via `linkImageUrl`, and PDF page 1 rendered client-side by a **lazily dynamic-imported** `pdfjs` ([`render-pdf-thumbnail.ts`](/src/renderer/src/features/drip/render-pdf-thumbnail.ts), kept out of the main renderer bundle). Thumbnails are **lazy** — loaded only as a row nears the viewport via the shared [`useNearViewport`](/src/renderer/src/hooks/useNearViewport.ts) hook, since the queue list isn't virtualized — and any load failure falls back to the content-type glyph, never a broken-image box. Heavy previews (video first-frame, PDF render) are **desktop-only**: on the mobile web embed a video/PDF row keeps its glyph, matching the inbox player's `preload="none"` OOM guard (images + links still preview on mobile). The component is [`DripItemThumbnail`](/src/renderer/src/features/drip/DripItemThumbnail.tsx), wired into [`DripQueueRow`](/src/renderer/src/features/drip/DripQueueRow.tsx); its fixed-size `object-cover` `<img>` is a deliberate plain `<img>` (the Avatar / LibraryCard fixed-size-thumbnail category, seeded in [`raw-img-use.test.ts`](/tests/unit/lint/raw-img-use.test.ts)), not the zoomable `ImageCanvas` viewer. Locked by [`DripItemThumbnail.test.tsx`](/tests/unit/components/DripItemThumbnail.test.tsx).

**Right-click actions.** A released drip's content is right-clickable like the rest of the app (desktop/Electron only — the image actions use an Electron-only IPC, and touch has no right-click). Right-click an **image** for Copy Image / Copy File Name / Save Image As (the app's shared `ViewerImageContextMenu`, reusing the already-loaded data-URL bytes — no re-fetch), a **link** (an inline markdown link or a link-type item) for Open Link / Copy Link Address, or **text** for Edit Text (opens the inline message-text editor — text items only, since the footer Edit button now opens the whole-drip editor instead), Copy Text (copies the item body without selecting first), and Select All. The gap this closed: the app's global right-click is a _text_ menu that bails when nothing is selected, and image menus are wired per component — the drip image had none, so a drip gave nothing on right-click. The image case is wired in [`DripInlineImage`](/src/renderer/src/features/drip/DripInlineImage.tsx) (it owns the bytes, and `stopPropagation`s); a single region handler on the inbox viewer's content area ([`DripInboxViewer`](/src/renderer/src/features/drip/DripInboxViewer.tsx)) routes text/links to [`DripContentContextMenu`](/src/renderer/src/features/drip/DripContentContextMenu.tsx) on the shared `MenuShell`, and **defers to the app's global text menu whenever you have a live selection** so there's never a double menu. The sandboxed HTML preview (a separate document) is unchanged. Full invariants + the tests that lock them: [`.claude/memory/contracts/drip-content-context-menu-contract.md`](/.claude/memory/contracts/drip-content-context-menu-contract.md).

**Edit a text item's body.** A `text` item can be edited in place — this edits the item's **words**, distinct from the header pencil / name-link that edit the drip's **schedule and settings**. In the inbox viewer the text edit is reached by **right-click → Edit Text** on the message (the footer Edit button now opens the whole-drip editor, so the per-message text edit moved to the content menu — text items only); it swaps the rendered Markdown for an editable textarea with Save/Cancel (Ctrl+Enter saves, Escape cancels). The Drips management pane's queue rows carry a matching **Edit** button that opens the same editor in a dialog, so a still-**pending** item can be fixed before it fires. Both surfaces share one [`DripItemEditor`](/src/renderer/src/features/drip/DripItemEditor.tsx) and one optimistic `editItemContent` store action (patches the inbox list + detail + any cached panel row by the shared id, with rollback on failure). The write is a **dual-write by the shared id**: a released item is dual-backed by its `drip_items` row (the panel) AND its `inbox_alert_items` row (the unified inbox reads its text from THERE, not from `drip_items`), so the one chokepoint [`updateInboxDripItemText`](/src/main/services/inbox/inbox-drip-edit.ts) updates both atomically inside one transaction — the sibling of the archive chokepoint. A still-pending item has no alert row yet, so only the `drip_items` row updates and the eventual release copies the edited text into the new alert. Only `text` items are editable (a `link` edit would strand its og-metadata; `file` / `book-pages` aren't free-form text); editing an **archived** item works from the Archived tab and never un-archives it. IPC `DRIP_ITEM_UPDATE`; CLI `PATCH /drip/items/:itemId` (apply-immediate). Full invariants + the tests that lock them: [`.claude/memory/contracts/drip-edit-path-contract.md`](/.claude/memory/contracts/drip-edit-path-contract.md).

## For agents

Data lives in five tables: `drips` (one per named queue — cron, status, counters), `drip_items` (one per item with the typed content discriminator, position in queue, release/archive timestamps, plus `book_source_id` / `book_unit_start` / `book_unit_end` for `book-pages` items), `drip_folder_sources` (watched folders), `drip_folder_seen_files` (scanner bookkeeping), and `drip_book_sources` (one row per PDF / EPUB attached to a drip — kind + total*units + on-disk path). Schema migration is v195 in [`src/main/db/database.ts`](/src/main/db/database.ts); v220 adds the three `book*_`columns to`drip*items`and the`drip_book_sources`table; v221 widens the`drip_items.content_type`CHECK from five values to six to admit`divider`(a`legacy_alter_table`rebuild that preserves every row, both partial indexes, and the v220`book*_` columns). Soft-delete (`is_deleted`) on `drips`, `drip_items`, and `drip_book_sources` with cascade in JS, not SQL — the same pattern as projects / sessions.

Shared types live in [`src/shared/drip-types.ts`](/src/shared/drip-types.ts) — `Drip`, `DripItem`, `DripFolderSource`, `DripBookSource`, `BookSourceKind`, plus the five push payload types (`DripItemReleasedPayload`, `DripReleaseSkippedPayload`, `DripArchivedPayload`, `DripItemAddedPayload`, `DripChangedPayload`) and the input shapes for `DRIP_CREATE` / `DRIP_UPDATE` / `DRIP_ITEM_ADD` / `DRIP_BOOK_SOURCE_CREATE`.

The book extractor lives in [`src/main/services/drip/drip-book-extractor-service.ts`](/src/main/services/drip/drip-book-extractor-service.ts). The unified create-time entry point is `extractBookTotalUnits(filePath, kind)`, which sniffs magic bytes, dispatches to one of three strategies (`countPdfPages` for `kind=pdf`, `extractEpubChapters` for `kind=epub-chapter`, `extractEpubWordChunks` for `kind=epub-word`), and returns `{ ok: true, totalUnits }` or `{ ok: false, reason }`. `sanitizeHtml` (file-private) extracts `body.innerHTML` from the source XHTML before sanitising — DOMPurify 3.4.3 auto-detects full-document mode when input contains `<html>` and would otherwise leak `<head><title>` text into chapter body text. Do not change this without re-reading [the recovery postmortem](/.claude/memory/postmortems/drip-book-pages-recovery-postmortem.md).

The integration is registered in [`src/shared/integration-registry.ts`](/src/shared/integration-registry.ts) with `kind: 'amc-builtin'`, `virtualProjectPath: DRIP_PROJECT_ID` (the `__drip__` sentinel), `featureFlag: 'dripEnabled'`, `settingsSearchId: 'drip-enabled'`, and the full push / inbox-source / criticalPushChannels claims. The integration-completeness contract test ([`tests/unit/lint/integration-completeness.test.ts`](/tests/unit/lint/integration-completeness.test.ts)) fails CI if any claim drifts from its backing wiring.

### CLI surface

Drip has a full CLI surface on `127.0.0.1:19519` for agent automation and scripted workflows (skills, cron, email handlers). All 24 routes are registered via `registerDripRoutes()` (from [`src/main/services/cli/cli-server-drip-routes.ts`](/src/main/services/cli/cli-server-drip-routes.ts), route definitions in the `src/main/services/cli/drip/` subfolder) wired from [`src/main/index.ts`](/src/main/index.ts). The route family parallels the IPC handlers in `handlers-drip.ts` with one carve-out: `DRIP_BULK_PARSE_PREVIEW` (pure client-side helper) and `DRIP_ITEM_LIST` / `DRIP_FOLDER_SOURCE_LIST` (folded into `GET /drip/:id`) do not get standalone CLI routes.

**Gating** follows the same approval-vs-apply-immediate split as the rest of the CLI server. **Drip-level CRUD** (`POST /drip`, `PATCH /drip/:id`, `DELETE /drip/:id`) is approval-gated because each drip is a recurring side-effect schedule — same trust boundary as cron/automation. **Filesystem sources** (`POST /drip/:id/folder-sources`, rescan, `folder-import-once`, `POST /drip/:id/book-sources`, and the `DELETE` counterparts) are approval-gated because they read files at the path the caller specifies — same trust boundary as `/project/:id/docs`. **Item-level queue management** (`POST /drip/:id/items`, `bulk`, `reorder`, `pause`, `resume`, `PATCH /drip/items/:itemId` (edit a text item's body), `DELETE /drip/items/:itemId`, `archive`, `unarchive`, `snooze`) is apply-immediately because adding/removing/reordering/editing pending items is reversible and per-user queue noise rather than a recurring schedule change.

**Idempotency**: every approval-gated route accepts an optional `X-Client-Request-Id` header; same header on the same `action_kind` within the 30-day window returns the existing pending row with `idempotent: true` instead of inserting a duplicate.

**File uploads**: `POST /drip/:id/items` accepts base64 in the JSON body (`fileBase64` + `fileName` together). This route carries a RAISED per-route body cap (`DRIP_ITEM_ADD_MAX_BODY_SIZE` ≈ 7 MB, derived from the 5 MB live-preview render budget so any file the drip can render is acceptable over the CLI) — every OTHER CLI route keeps the shared 1 MB cap; files beyond the render budget must use the in-app picker. The dispatch path goes through `drip-file-storage.storeDripFile` — the same service the IPC handler uses, so the `userData/drip/<dripId>/` layout stays in one place. The per-route cap mechanism + its invariants: [.claude/memory/contracts/drip-add-path-contract.md](/.claude/memory/contracts/drip-add-path-contract.md).

**Read-style POST**: `POST /drip/cron-preview` is read-budgeted (no state mutation) and returns `data: { ok: true, nextRuns: [...] }` for a valid cron or `data: { ok: false, reason: '...' }` for an invalid one — both at HTTP 200 — so CLI consumers can render live cron-validation inline the same way the in-app form does. This mirrors the IPC handler's shape exactly.

The dispatcher's 9 drip arms (`drip.create`, `drip.update`, `drip.delete`, `drip.folder_source_add/remove/rescan`, `drip.folder_import_once`, `drip.book_source_add/remove`) live in [`src/main/services/cli/cli-pending-dispatcher.ts`](/src/main/services/cli/cli-pending-dispatcher.ts). Each one re-validates the payload against its original Zod schema at approve time (so a codebase change between enqueue and approve fails fast as a permanent error), invokes the same query-layer functions the IPC handlers use, and emits the appropriate `DRIP_CHANGED` / `DRIP_ITEM_ADDED` push so open UI re-renders. Integration coverage in [`tests/integration/cli-server-drip-flow.test.ts`](/tests/integration/cli-server-drip-flow.test.ts) exercises one canonical example per gating category.

**Session provenance (AI-created drips) — recording it is REQUIRED, not best-effort.** A drip is a recurring publisher into your inbox and its items are content you actually read, so neither can be created anonymously over the CLI. The three **content-creating** routes — `POST /drip`, `POST /drip/:id/items`, `POST /drip/:id/items/bulk` — refuse a caller that can name neither a session nor a validated cron, with a `400` naming the `X-AMC-Source-Session-Id` header, **before anything is written**. (Every Omniscio-spawned agent proves its own session through its token, so it passes without sending the header. The other drip routes — pause, resume, reorder, archive, snooze, delete, `PATCH` — create no content and are deliberately unguarded.)

Two columns record it, and they answer different questions. `drips.source_session_id` records who created the **queue**; `drip_items.source_session_id` records who **filled** it — and they legitimately differ when one session makes a drip that another adds to. The item column is stamped for every item kind (text, link, file, divider) and every row of a bulk add. Both are nullable, and NULL is the correct, common answer: a drip or item you made by hand, a folder-scanner or book-source item, a Tasks trickle, or a cron-driven add has no originating session.

You see it in two places, both detail-only: the drip's **Edit cadence modal**, and — new — the **drip item's inbox detail**, where a clickable **"Generated by ‹session›"** link sits beside the released time and jumps back to the session that put the card there. The inbox reads that link from the item's own column via a join, deliberately **not** by handing the session to `createAlert` (that input also drives which project an alert is filed under, and doing so would scatter drip items out of the Drips inbox). Full invariants: [session-provenance-contract](/.claude/memory/contracts/session-provenance-contract.md) — **I19** (the guard, the item column, the render, and the known idempotency gap), plus I2 (columns) and I6 (render surfaces).

Full route list and gating per family: [.claude/memory/cli-server-gating.md](/.claude/memory/cli-server-gating.md).

## Related

[Drip](drip.md) is the overview and the user-facing half of this feature — what a drip is, how to fill one, and how its cadence behaves. The contract pages linked throughout this half hold the invariants and the tests that lock them.
