Product shape
BYOK Reddit channel in conv, plus outreach extensions so the org's own agent runs the loop: probe Reddit with live-read tools → qualify against the campaign brief and subreddit rules → propose either a DM to the OP or a public comment in the thread → human approves → Munin posts, ingests replies, and the normal conversation loop takes over. Munin does no prospecting; the skill markdown carries the judgment, the service carries the invariants.
Out of scope: Reddit Chat (no API), sequence follow-ups for threads (comment-bumping is ban-bait), delivery/read receipts (don't exist), any Munin-owned Reddit app or central API agreement (BYOK per org).
Reddit API facts (verified July 2026)
- Legacy private messages still fully supported:
POST /api/compose (scope privatemessages), GET /message/unread + /message/inbox, POST /api/read_message. Reddit Chat (the modern DM UI) has no public API — our DMs land in the "Messages" inbox, not Chat, so deliverability-to-attention is lower than the API success response suggests.
- Thread posting:
POST /api/submit / POST /api/comment (scope submit). Subreddit rules via /r/{sub}/about/rules.
- No webhooks anywhere — inbound is polling only (matches the email adapter's poll mode).
- Free tier is non-commercial, 100 QPM per OAuth client. With BYOK the customer is the API consumer of record — commercial-approval obligation transfers to them (documented in the setup skill, not Munin's problem to negotiate).
- Effective DM send limits are much tighter than the nominal rate limit; fresh/low-karma accounts get sends rejected. Account warming + conservative pacing are hard requirements.
PR 1 — feat/contact-handle-identity
Foundation both paths need: username-based identity.
- Schema: add
handle (text, nullable) to conv_contacts and crm_contacts + org-scoped indexes ((org_id, handle)), mirroring the email/phone pattern. Migration renamed per convention; no backfill, but smoke-test against the seeded local DB.
- Ingest: extend
fromIdentity in channels/adapter.ts with handle?: string; teach findOrCreateContact() (channels/channel-ingest.service.ts) to look up/create by (orgId, handle); end-user externalId pattern reddit:{username} alongside existing email:{…}/phone:{…}.
- CRM: expose
handle through crm_create_contact/crm_update_contact/crm_find_contact (Zod + DTOs).
- Tests: ingest identity resolution, dedup across handle/email, RLS.
PR 2 — feat/reddit-channel-dm (largest single PR)
Adapter + DM path end-to-end, in packages/backend-core/src/modules/conv/reddit/:
reddit-client.ts — thin API client: script-app password grant (clientId/secret + username/password), token caching, mandatory descriptive User-Agent, 429/X-Ratelimit-* handling, and channel-level send pacing (minimum interval between writes, conservative default) enforced in the adapter, not left to retry logic — failures here burn the customer's account.
reddit-adapter.ts — ChannelAdapter, kind: 'chat', vendors: ['reddit']. send(): DM via POST /api/compose (recipient = contact handle), returns providerMessageId. inbound: { mode: 'poll' }: tick reads /message/unread, ingests DMs with fromIdentity: { handle }, cursor in conv_inbound_state, marks read via /api/read_message.
reddit-admin.provider.ts — ChannelAdminProvider, Zod config schema, secrets pgcrypto-encrypted like IMAP passwords, test() = GET /api/v1/me, sendTest() = DM to a given username. Register both in conv.module.ts factory arrays.
- Outreach identity check: mirror the voice/phone check — Reddit channel requires
contact.handle. Gate the unsubscribe footer by channel type: never append a footer URL on Reddit; the skill covers "reply to opt out" + doNotContact semantics instead.
- Skill:
skill://conv/setup-reddit-channel — app registration at reddit.com/prefs/apps (script type), customer is the API consumer bound by Reddit's Data API terms (commercial approval is their obligation), account warming/karma requirements, pacing expectations.
- Tests: adapter send/poll against a mocked Reddit API, conflict/validation paths, admin provider test flow, JSON-RPC smoke of the new surface (no bare 500s /
-32602).
Milestone: DM outreach works end-to-end (agent creates CRM contact with handle + legitimate_interest, proposes, human approves, DM sends, replies ingest).
PR 3 — feat/outreach-engagement-campaigns
Schema + service changes making outreach thread-aware:
outreach_campaigns: add kind ('segment' default | 'engagement'); segmentId nullable, service-enforced required for kind='segment' (pre-check + CHECK constraint backstop). Backfill existing rows → 'segment' — touches FORCE-RLS tables → set_config('app.bypass_rls','on',true) in a DO block, verified as munin_app.
outreach_proposals: contactId nullable; new kind 'thread_comment'; target jsonb ({ threadId, permalink, subreddit, title, opHandle }). Partial unique indexes on (campaignId, (target->>'threadId')) for pending and for approved/sent — DB-level "never comment twice in one thread".
cadenceRules: add maxPerWeekPerSubreddit, maxCommentsPerDay; propose-time SQL check grouped by target->>'subreddit', same pattern as the existing per-contact check.
- Service branches: contact-target proposals keep today's consent/suppression path untouched; thread-target proposals skip consent checks (public speech, no data subject) and run subreddit cadence + thread dedup instead.
- Migration renumbered/renamed, backfill smoke-tested on seeded scratch DB.
PR 4 — feat/reddit-thread-engagement
- New tool
outreach_propose_thread_comment (separate tool per the split-read-write convention): campaignId + target + draftBody + evidence. approveProposal branches: thread proposals create a conversation with contactId: null, thread metadata, subject = thread title, outreachCampaignId stamped; delivery flows through the normal queue; adapter.send() routes on conversation metadata — thread conversation → POST /api/comment, DM conversation → /api/compose.
conversationKey ingest extension: optional key on InboundBatch messages; ChannelIngestService resolves conversation by (channelId, key) before falling back to contact-based resolution. Poll tick maps comment replies (parent chain → our comment) to the thread conversation; each replier becomes a handle-based conv contact, authored per message (convMessages.authorId already supports multi-author).
proposeReply: lift the email-only restriction to admit Reddit conversations — thread replies and DM replies both route through the campaign's approval queue.
- Engagement signal: poll tick refreshes our posted comments' score/reply count into conversation metadata.
- Tests: thread proposal validation (dedup conflict, subreddit cadence, no consent check), approve→send→reply-ingest integration, multi-author thread conversation.
PR 5 — feat/reddit-read-tools-and-playbook
- Read tools in
conv/reddit/reddit.tools.ts, all readOnlyHint, admin audience, conv:read: conv_reddit_search_threads (subreddit/keyword search, bounded payload), conv_reddit_get_thread (post + comment tree, truncated), conv_reddit_get_subreddit_rules. All take channelId to select BYOK credentials.
- Playbook
skill://playbooks/reddit-engagement (crosses conv + outreach + crm): the probe → qualify → decide → propose loop. Encodes what code can't enforce: check subreddit rules first and attach them to evidence; prefer the public comment for open questions; DM-to-OP only when a public reply would be off-topic or forbidden — and it's the socially riskier move; always disclose affiliation; create the CRM contact with consentLawfulBasis: 'legitimate_interest' before a DM; set doNotContact on any opt-out; low volume, genuine helpfulness. Cross-link from the setup skill.
- Dashboard: verify the channels page picks up the vendor via
conv_list_channel_vendors discovery; add card/branding if needed (packages/dashboard-pages). No Integrations-page work — Reddit is a channel, not a bridge/connector.
Cross-cutting
- Changesets per PR:
@getmunin/backend-core, @getmunin/db, @getmunin/types, @getmunin/dashboard-pages as touched.
- Land order is the PR order; 1→2 and 3→4 are hard dependencies, PR 3 can proceed in parallel with 2. Renumber migrations at land time.
- Docs regen (
docs:generate) in every PR touching tools/skills.
Design decisions taken (open for veto)
- Generic
handle column (not metadata.redditUsername, not vendor-namespaced jsonb) — bets that Discord/X/Bluesky reuse it as-is.
- Script-app password grant for BYOK v1 — no OAuth redirect flow on Munin; matches the IMAP precedent. Authorization-code flow can come later if Reddit tightens script apps.
kind='engagement' as a generic campaign discriminator rather than 'reddit_engagement' — same model should serve future forum-like channels.
- Thread comments skip consent checks entirely — treated as public speech under subreddit cadence rules, not person-directed outreach. Compliance-posture decision worth a deliberate yes.
Product shape
BYOK Reddit channel in
conv, plus outreach extensions so the org's own agent runs the loop: probe Reddit with live-read tools → qualify against the campaign brief and subreddit rules → propose either a DM to the OP or a public comment in the thread → human approves → Munin posts, ingests replies, and the normal conversation loop takes over. Munin does no prospecting; the skill markdown carries the judgment, the service carries the invariants.Out of scope: Reddit Chat (no API), sequence follow-ups for threads (comment-bumping is ban-bait), delivery/read receipts (don't exist), any Munin-owned Reddit app or central API agreement (BYOK per org).
Reddit API facts (verified July 2026)
POST /api/compose(scopeprivatemessages),GET /message/unread+/message/inbox,POST /api/read_message. Reddit Chat (the modern DM UI) has no public API — our DMs land in the "Messages" inbox, not Chat, so deliverability-to-attention is lower than the API success response suggests.POST /api/submit/POST /api/comment(scopesubmit). Subreddit rules via/r/{sub}/about/rules.PR 1 —
feat/contact-handle-identityFoundation both paths need: username-based identity.
handle(text, nullable) toconv_contactsandcrm_contacts+ org-scoped indexes ((org_id, handle)), mirroring the email/phone pattern. Migration renamed per convention; no backfill, but smoke-test against the seeded local DB.fromIdentityinchannels/adapter.tswithhandle?: string; teachfindOrCreateContact()(channels/channel-ingest.service.ts) to look up/create by(orgId, handle); end-userexternalIdpatternreddit:{username}alongside existingemail:{…}/phone:{…}.handlethroughcrm_create_contact/crm_update_contact/crm_find_contact(Zod + DTOs).PR 2 —
feat/reddit-channel-dm(largest single PR)Adapter + DM path end-to-end, in
packages/backend-core/src/modules/conv/reddit/:reddit-client.ts— thin API client: script-app password grant (clientId/secret + username/password), token caching, mandatory descriptive User-Agent, 429/X-Ratelimit-*handling, and channel-level send pacing (minimum interval between writes, conservative default) enforced in the adapter, not left to retry logic — failures here burn the customer's account.reddit-adapter.ts—ChannelAdapter,kind: 'chat',vendors: ['reddit'].send(): DM viaPOST /api/compose(recipient = contact handle), returnsproviderMessageId.inbound: { mode: 'poll' }: tick reads/message/unread, ingests DMs withfromIdentity: { handle }, cursor inconv_inbound_state, marks read via/api/read_message.reddit-admin.provider.ts—ChannelAdminProvider, Zod config schema, secrets pgcrypto-encrypted like IMAP passwords,test()=GET /api/v1/me,sendTest()= DM to a given username. Register both inconv.module.tsfactory arrays.contact.handle. Gate the unsubscribe footer by channel type: never append a footer URL on Reddit; the skill covers "reply to opt out" +doNotContactsemantics instead.skill://conv/setup-reddit-channel— app registration at reddit.com/prefs/apps (script type), customer is the API consumer bound by Reddit's Data API terms (commercial approval is their obligation), account warming/karma requirements, pacing expectations.-32602).Milestone: DM outreach works end-to-end (agent creates CRM contact with handle +
legitimate_interest, proposes, human approves, DM sends, replies ingest).PR 3 —
feat/outreach-engagement-campaignsSchema + service changes making outreach thread-aware:
outreach_campaigns: addkind('segment'default |'engagement');segmentIdnullable, service-enforced required forkind='segment'(pre-check + CHECK constraint backstop). Backfill existing rows →'segment'— touches FORCE-RLS tables →set_config('app.bypass_rls','on',true)in a DO block, verified asmunin_app.outreach_proposals:contactIdnullable; new kind'thread_comment';targetjsonb ({ threadId, permalink, subreddit, title, opHandle }). Partial unique indexes on(campaignId, (target->>'threadId'))for pending and for approved/sent — DB-level "never comment twice in one thread".cadenceRules: addmaxPerWeekPerSubreddit,maxCommentsPerDay; propose-time SQL check grouped bytarget->>'subreddit', same pattern as the existing per-contact check.PR 4 —
feat/reddit-thread-engagementoutreach_propose_thread_comment(separate tool per the split-read-write convention): campaignId + target + draftBody + evidence.approveProposalbranches: thread proposals create a conversation withcontactId: null, thread metadata, subject = thread title,outreachCampaignIdstamped; delivery flows through the normal queue;adapter.send()routes on conversation metadata — thread conversation →POST /api/comment, DM conversation →/api/compose.conversationKeyingest extension: optional key onInboundBatchmessages;ChannelIngestServiceresolves conversation by(channelId, key)before falling back to contact-based resolution. Poll tick maps comment replies (parent chain → our comment) to the thread conversation; each replier becomes a handle-based conv contact, authored per message (convMessages.authorIdalready supports multi-author).proposeReply: lift the email-only restriction to admit Reddit conversations — thread replies and DM replies both route through the campaign's approval queue.PR 5 —
feat/reddit-read-tools-and-playbookconv/reddit/reddit.tools.ts, allreadOnlyHint, admin audience,conv:read:conv_reddit_search_threads(subreddit/keyword search, bounded payload),conv_reddit_get_thread(post + comment tree, truncated),conv_reddit_get_subreddit_rules. All takechannelIdto select BYOK credentials.skill://playbooks/reddit-engagement(crosses conv + outreach + crm): the probe → qualify → decide → propose loop. Encodes what code can't enforce: check subreddit rules first and attach them toevidence; prefer the public comment for open questions; DM-to-OP only when a public reply would be off-topic or forbidden — and it's the socially riskier move; always disclose affiliation; create the CRM contact withconsentLawfulBasis: 'legitimate_interest'before a DM; setdoNotContacton any opt-out; low volume, genuine helpfulness. Cross-link from the setup skill.conv_list_channel_vendorsdiscovery; add card/branding if needed (packages/dashboard-pages). No Integrations-page work — Reddit is a channel, not a bridge/connector.Cross-cutting
@getmunin/backend-core,@getmunin/db,@getmunin/types,@getmunin/dashboard-pagesas touched.docs:generate) in every PR touching tools/skills.Design decisions taken (open for veto)
handlecolumn (notmetadata.redditUsername, not vendor-namespaced jsonb) — bets that Discord/X/Bluesky reuse it as-is.kind='engagement'as a generic campaign discriminator rather than'reddit_engagement'— same model should serve future forum-like channels.