Outreach Ledger
Give a project a nervous system for its in-flight conversations: a ledger of every thread,
due-touch flags, and optional read-only inbox polling. The system observes and reminds;
humans draft and send.
Reference implementation ships in this folder: outreach.mjs (ledger + CLI),
outreach-poll.mjs (read-only IMAP poller with reply AND bounce detection),
seed.example.json. Copy and adapt rather than reinvent. Requires node; npm install in
this folder pulls the two dependencies (better-sqlite3, imapflow).
Recipe
- Scope the threads. List every conversation the project has in flight: who, org,
email (if known), channel, current state, what was last sent and when. Ask the user to
confirm the list — seeds are facts, not guesses.
- Ledger table in the project's existing store (SQLite via
outreach.mjs, or a
markdown/JSON ledger for code-free projects): target, org, email, thread_subject,
channel, status (drafted → sent → replied → in_conversation → terms_sent → signed → dormant → closed, plus bounced as a dead-end — rename states to fit the domain),
doctrine, dates, touch_count, notes. Seed idempotently (re-running the seed must add nothing).
- CLI or checklist:
--list (grouped by status), --touch <id> (update state +
next date), --due (rows where next_touch_at ≤ today, each with a suggested action).
- Due-action wording matters: warm/personal relationships → "human touch due — nudge
the user, no draft"; cold/transactional sends → "day-N follow-up due — draft with
approval". Third un-replied touch → suggest marking dormant.
- Inbox poller (optional, needs mailbox creds in a gitignored .env): IMAP scan since
last-seen UID; store the UID cursor. Two reconciliations, both mandatory:
- Reply: a From match on a watched address → flip to
replied, surface an alert.
- Bounce: a delivery-status/NDR from
mailer-daemon/postmaster → parse the failed
recipient from the body and flip that row to bounced. Without it, a dead address sits
at sent forever, indistinguishable from awaiting-reply — the precise failure this skill
exists to kill. A reply-only poller is not "working fully".
Read-only per the hard rules below.
Hard rules (do not soften)
- No auto-send code path exists, anywhere. The ledger flags; drafting happens with the
user; the user approves every outbound. Do not build a "send follow-up" function even
behind a flag.
- Respect the project's contact doctrine. Some threads must never be nudged (a client
who comes to you; a relationship where chasing costs more than silence). Set
doctrine: "no-chase" — tracked in --list, structurally excluded from --due.
- Poller is read-only — open folders read-only, touch no flags, unread stays unread.
- Bounces are a state, not a silence. The poller must detect NDRs and mark the row
bounced; a dead address masquerading as awaiting-reply defeats the whole ledger.
- Credentials live in the project's gitignored env file, never committed, never echoed.
Verify before finishing
Run the seed twice (second run = 0 changes). Run --due and sanity-check the suggested
actions against the doctrine rules. If a poller was wired: one --once run against the
real inbox, confirm it reports without mutating (a message's unread state must survive),
and confirm a bounce flips its row to bounced (send one deliberately-bad-address test,
or check a past NDR is caught) — not just that replies flip to replied.
1---2name: outreach-ledger3description: Outreach Ledger4---56# Outreach Ledger78Give a project a nervous system for its in-flight conversations: a ledger of every thread,9due-touch flags, and optional read-only inbox polling. The system observes and reminds;10humans draft and send.1112**Reference implementation ships in this folder**: `outreach.mjs` (ledger + CLI),13`outreach-poll.mjs` (read-only IMAP poller with reply AND bounce detection),14`seed.example.json`. Copy and adapt rather than reinvent. Requires node; `npm install` in15this folder pulls the two dependencies (better-sqlite3, imapflow).1617## Recipe18191. **Scope the threads.** List every conversation the project has in flight: who, org,20 email (if known), channel, current state, what was last sent and when. Ask the user to21 confirm the list — seeds are facts, not guesses.222. **Ledger table** in the project's existing store (SQLite via `outreach.mjs`, or a23 markdown/JSON ledger for code-free projects): target, org, email, thread_subject,24 channel, status (`drafted → sent → replied → in_conversation → terms_sent → signed →25 dormant → closed`, plus `bounced` as a dead-end — rename states to fit the domain),26 doctrine, dates, touch_count, notes. Seed idempotently (re-running the seed must add nothing).273. **CLI or checklist**: `--list` (grouped by status), `--touch <id>` (update state +28 next date), `--due` (rows where next_touch_at ≤ today, each with a suggested action).294. **Due-action wording matters**: warm/personal relationships → "human touch due — nudge30 the user, no draft"; cold/transactional sends → "day-N follow-up due — draft with31 approval". Third un-replied touch → suggest marking dormant.325. **Inbox poller (optional, needs mailbox creds in a gitignored .env)**: IMAP scan since33 last-seen UID; store the UID cursor. Two reconciliations, both mandatory:34 - **Reply**: a From match on a watched address → flip to `replied`, surface an alert.35 - **Bounce**: a delivery-status/NDR from `mailer-daemon`/`postmaster` → parse the failed36 recipient from the body and flip that row to `bounced`. Without it, a dead address sits37 at `sent` forever, indistinguishable from awaiting-reply — the precise failure this skill38 exists to kill. A reply-only poller is not "working fully".39 Read-only per the hard rules below.4041## Hard rules (do not soften)4243- **No auto-send code path exists, anywhere.** The ledger flags; drafting happens with the44 user; the user approves every outbound. Do not build a "send follow-up" function even45 behind a flag.46- **Respect the project's contact doctrine.** Some threads must never be nudged (a client47 who comes to you; a relationship where chasing costs more than silence). Set48 `doctrine: "no-chase"` — tracked in `--list`, structurally excluded from `--due`.49- **Poller is read-only** — open folders read-only, touch no flags, unread stays unread.50- **Bounces are a state, not a silence.** The poller must detect NDRs and mark the row51 `bounced`; a dead address masquerading as awaiting-reply defeats the whole ledger.52- **Credentials** live in the project's gitignored env file, never committed, never echoed.5354## Verify before finishing5556Run the seed twice (second run = 0 changes). Run `--due` and sanity-check the suggested57actions against the doctrine rules. If a poller was wired: one `--once` run against the58real inbox, confirm it reports without mutating (a message's unread state must survive),59and confirm a bounce flips its row to `bounced` (send one deliberately-bad-address test,60or check a past NDR is caught) — not just that replies flip to `replied`.