Skip to content

Reddit channel: agent-driven engagement (DMs + thread comments) #654

Description

@kmonsoe

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.tsChannelAdapter, 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.tsChannelAdminProvider, 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)

  1. Generic handle column (not metadata.redditUsername, not vendor-namespaced jsonb) — bets that Discord/X/Bluesky reuse it as-is.
  2. 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.
  3. kind='engagement' as a generic campaign discriminator rather than 'reddit_engagement' — same model should serve future forum-like channels.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions