Automate Reddit DM replies from a CLI

Reddit Chat is a Matrix v3 client. The entire unread inbox lives in your browser's IndexedDB, populated by the Reddit web app on every page load. A CLI that reads that store and drives the same logged-in Chrome session can poll, classify, and reply to DMs without touching any official API. There isn't one. Here is what actually works, with the store name, the extraction JS, and a working command.

M
Matthew Diakonov
9 min
Direct answer (verified 2026-05-01)

There is no official Reddit Chat API. The Reddit-documented API only covers legacy private messages, not the modern Chat product, and PRAW / snoowrap do not support Chat at all. The pragmatic CLI pattern, used in production by the open-source S4L repo, is two parts:

  1. Read: open reddit.com/chat in headless Chromium with a persistent profile, wait for matrix-js-sdk to incremental-sync, then read matrix-js-sdk:reddit-chat-sync from IndexedDB. That gives you every joined room, every unread count, every recent message body, and the partner username. No API call originates from your CLI.
  2. Reply: navigate the same browser to https://www.reddit.com/chat/room/<room_id>, type into the "Write message" textbox, press Enter, then re-read the DOM to verify the message appeared before logging it.

Reference implementation: github.com/m13v/social-autoposter (scripts/reddit_chat_sync.py and scripts/reddit_browser.py).

The four-step CLI loop

  1. 1

    Reuse a logged-in profile

    Headless Chromium attached to a persistent profile that already has your Reddit session.

  2. 2

    Read unread inbox from IndexedDB

    Open matrix-js-sdk:reddit-chat-sync, walk roomsData.join, filter unread_notifications.

  3. 3

    Send a reply via the same session

    Navigate to the chat URL, type into the message box, press Enter, verify the DOM.

  4. 4

    Log outbound + dedup inbound

    Upsert the conversation row, store every Matrix event_id as the dedup key for inbound.

Why nobody has packaged this as a library

Almost every guide that ranks today for this topic points you at PRAW. PRAW is excellent for the Reddit API it actually covers: posts, comments, subreddits, modqueue, and the legacy private messages product at /message/inbox. But Reddit's modern DM product is not that endpoint. It is Reddit Chat, the SPA at reddit.com/chat, and Reddit Chat is a vanilla matrix-js-sdk client talking to a private Matrix homeserver Reddit operates.

Matrix is a federated chat protocol with a public spec and a mature open-source SDK. Reddit just consumes it. That has two consequences worth noticing for a CLI author:

  • The client cache is durable and well-documented. matrix-js-sdk persists every joined room, every unread count, and the recent timeline into IndexedDB under a single store. The schema is stable upstream. Reddit cannot quietly change it without rewriting the client.
  • There is no public OAuth surface. The Matrix homeserver only authenticates Reddit's own client, not yours. So a from-scratch HTTP client would have to spoof what the React app does on every page load, including device handshake and key rotation. Three months of work, one Reddit redesign away from breaking.

Driving a logged-in headless Chromium and reading the store the React app already populated is the smaller, more durable surface area. You inherit Reddit's own client's auth, sync, and key handling for free, and your CLI never originates a network call that Reddit's anti-abuse layer would not have seen anyway.

Step 1. Open Chromium against a persistent profile

Use Playwright's launch_persistent_context with a profile directory you own. Log in once via headed mode and accept the Chat permissions prompt. From then on, every headless run reuses the cookies, the localStorage, and the IndexedDB store you populated.

Two operational rules matter here. First, only one process at a time can hold the profile; Chromium will refuse to start with "profile in use" if a stale process holds the file lock. The reference uses a small JSON lock file with a 300-second expiry under ~/.claude/reddit-agent-lock.json to coordinate. Second, the user agent has to look like a real desktop Chrome; the same profile in headless mode with the default Playwright UA gets challenged on /chat.

Step 2. Read the unread inbox from IndexedDB

Navigate to https://www.reddit.com/chat, wait roughly eight seconds for matrix-js-sdk to finish its incremental sync, then run this JS inside the page. It opens the store, reads the single sync row, and walks roomsData.join. The Reddit system bot @t2_1qwk:reddit.com is a member of every room and must be excluded from partner resolution.

// scripts/reddit_chat_sync.py (extracted JS, runs inside the page)
const REDDIT_SYSTEM_BOT = '@t2_1qwk:reddit.com';

const openReq = indexedDB.open('matrix-js-sdk:reddit-chat-sync');
const conn = await new Promise((res, rej) => {
  openReq.onsuccess = () => res(openReq.result);
  openReq.onerror   = () => rej(openReq.error);
});

const row = await new Promise((res, rej) => {
  const tx  = conn.transaction('sync', 'readonly');
  const req = tx.objectStore('sync').getAll();
  req.onsuccess = () => res(req.result[0] || null);
  req.onerror   = () => rej(req.error);
});
conn.close();

const join   = row.roomsData.join;
const unread = [];

for (const [roomId, r] of Object.entries(join)) {
  const nc = r.unread_notifications?.notification_count || 0;
  if (nc === 0) continue;

  const memberEvents = (r.state?.events || [])
    .filter(e => e.type === 'm.room.member');

  // Our mxid is the member whose displayname matches our Reddit username.
  const ourMxid = memberEvents.find(m =>
    m.content?.displayname === OUR_USERNAME
  )?.state_key || null;

  // The partner is the only member that is neither us nor the system bot.
  const partner = memberEvents.find(m =>
    m.state_key !== ourMxid &&
    m.state_key !== REDDIT_SYSTEM_BOT &&
    m.content?.displayname
  );

  unread.push({
    room_id: roomId,
    chat_url: 'https://www.reddit.com/chat/room/' + encodeURIComponent(roomId),
    unread_count: nc,
    partner_username: partner?.content?.displayname,
    timeline: (r.timeline?.events || []).slice(-30).map(e => ({
      event_id: e.event_id,           // Matrix event_id is the dedup key
      sender:   e.sender,
      ts:       e.origin_server_ts,
      body:     e.content?.body,
      from_us:  e.sender === ourMxid,
    })),
  });
}

The shape of each event matters for the inbound dedup pass. Matrix gives every event a globally-unique event_id, so a single UNIQUE constraint on dm_messages.event_id is enough to make the read pass idempotent. Run it every five minutes, every minute, on a webhook, on a cron; the worst case is a wasted lookup, never a duplicate row.

Step 3. Send the reply through the same session

Reuse the same browser context. Navigate to the chat URL you extracted in step two, find the "Write message" textbox, type into it, press Enter, and verify the message body actually appeared in the DOM before you commit anything to your DB. Do not trust the keystroke.

# scripts/reddit_browser.py (relevant excerpt)
def send_dm(chat_url, message, dm_id=None):
    with sync_playwright() as p:
        browser, page, _ = get_browser_and_page(p)        # persistent profile
        page.goto(chat_url, wait_until="domcontentloaded")
        page.wait_for_timeout(5000)

        msg_box = page.get_by_role("textbox", name="Write message")
        msg_box.wait_for(state="visible", timeout=10000)

        # Reddit Chat input is contenteditable, not <textarea>.
        tag = msg_box.evaluate("el => el.tagName.toLowerCase()")
        msg_box.click()
        if tag == "textarea":
            msg_box.fill(message)
        else:
            page.keyboard.type(message, delay=10)

        page.wait_for_timeout(1000)
        page.keyboard.press("Enter")                       # Reddit Chat sends on Enter
        page.wait_for_timeout(3000)

        # Verify by re-reading the DOM. Do not trust the press.
        verified = page.evaluate(
            "(s) => (document.body.textContent || '').includes(s)",
            message[:50],
        )
        return {"ok": verified, "thread_url": page.url, "verified": verified}

Two non-obvious details. First, the message input is contenteditable, not a <textarea>, so fill() silently drops the text. The branch on tagName exists for that. Second, the DOM verification is what distinguishes a real send from a UI flicker; the React app sometimes shows the message optimistically and then rolls it back if the homeserver rejects, so you want to wait three seconds and re-read.

Step 4. Close the loop in your DB

The CLI is more useful if it stamps two things back to a small local DB so the next run knows what changed. The reference schema has just two tables. dms holds one row per conversation: platform, partner username, chat_url, status. dm_messages holds one row per message: dm_id, direction (inbound / outbound), content, message_at, and the Matrix event_id as the UNIQUE dedup column. After every read pass, you upsert the conversation row and bulk-insert the new messages, ignoring duplicates by event_id. After every send, you log an outbound row with the verified content.

That is the entire state model. No subscriptions, no webhooks, no Matrix federation. The CLI is a cron-friendly poller that owns one row per conversation and one row per message.

What happens when you run the CLI

Your CLIHeadless ChromeReddit Chat (React)Matrix storelaunch persistent profilenavigate to /chatmatrix-js-sdk syncwrite roomsData.join to IndexedDBevaluate read JSindexedDB.open(matrix-js-sdk:reddit-chat-sync)unread rooms + timeline eventsJSON of unread inboxsend-dm chat_url messagetype + Enter into Write message boxDOM contains message bodyverified=true, thread_url

What that looks like in your terminal

Three commands. The first lists every unread room. The second ingests them into your DB and dedupes against any messages you already saw. The third sends a reply you composed elsewhere (LLM, template, your own typing) into the chat URL the first command returned.

Reddit DM CLI: read, ingest, reply

Why this approach beats the obvious one

The obvious approach when you go looking is "PRAW for messages". It works for one specific case (legacy /message inbox) and silently misses everything that flowed through Chat for the last three years. Toggle below to see why the Matrix-cache approach is more durable.

PRAW vs. the Matrix-cache CLI

Authenticate with client_id + client_secret + username + password against /api/v1/access_token. Call reddit.inbox.messages() to list legacy private messages. Call message.reply(text) to respond. Works exactly until the user moved the conversation to Reddit Chat, which is where 90%+ of new conversations live. Chat threads are invisible to PRAW. New conversations sent via /message/compose silently get redirected to Chat by Reddit's own UI; you cannot even start one through PRAW reliably.

  • Misses Reddit Chat entirely (the modern DM product)
  • Requires storing a cleartext Reddit password for username+password auth
  • Cannot send to a chat_url; only to legacy message threads

Constraints worth respecting

Volume. Reddit's anti-abuse layer watches DM cadence. The reference in the open-source repo intentionally tops out at single-digit sends per session and inserts human-shaped pauses (8 to 25 seconds, jittered). If you want to fan out a thousand cold DMs in an hour, the right answer is not a faster CLI; it is a different product (and a different account, soon enough).

Profile corruption. Two CLI invocations against the same Chromium profile at the same time will corrupt the IndexedDB store on the second one and you will get partial reads forever after. Either guard with a file lock (the reference does this with fcntl-style JSON lock) or use a separate profile per concurrent invocation.

Hydration timing. matrix-js-sdk syncs incrementally. If you read the store immediately after navigation, you will get a stale snapshot from the last session. The reference waits eight seconds before evaluating the read JS. On a slow connection, raise it to ten; on a fast wired one, six is plenty. The lock file should expire generously (the reference uses 300 seconds) so a Chromium startup hiccup does not orphan the lock.

Verification on send. Re-read the DOM. The React app shows messages optimistically. If the homeserver rejects (rate limit, banned, account flag), the UI rolls back silently. Three seconds after pressing Enter, confirm the first 50 characters of the message body are in the page text content before you commit a sent row.

Where this fits in a larger automation

By itself the CLI is a poller and a sender. The interesting shape is what you put between them. In the open-source reference, the loop is: list-unread every few minutes, ingest-unread to upsert the rows, classify each new inbound message against an ICP rubric for each product the operator runs, then draft a reply through an LLM that has the conversation history and the partner's public Reddit profile, then human-approve, then send-dm. Every step is its own subprocess so you can stop, inspect, redrive any single one without rebuilding the others. The CLI shape exists precisely so the human can stay in the loop on the steps where judgment matters.

Frequently asked

Frequently asked questions

Is there an official Reddit API for Chat (the modern DM product)?

No. Reddit's documented API (api.reddit.com, OAuth2 Bearer) covers legacy private messages at /api/compose and /message/inbox. Reddit Chat, the SPA at reddit.com/chat that almost all DM activity now flows through, is a separate product backed by a Matrix homeserver. There is no public client_credentials or OAuth path that will let a CLI send a Chat message. PRAW does not support Chat. Snoowrap does not support Chat. Anything that claims to is either driving a logged-in browser under the hood or is hitting unofficial undocumented endpoints that 401 without the right cookies.

Why read IndexedDB instead of just calling the Matrix homeserver directly?

You can call the homeserver directly if you reverse out the access_token from the page's local storage and the right device_id. That works for a few hours and then breaks the next time Reddit rotates auth. Reading IndexedDB is more durable: matrix-js-sdk syncs the unread inbox into the local store on every page load, so an automation that opens reddit.com/chat headless, waits for sync, and reads the store gets a fresh snapshot without making any new API calls itself. The Reddit web client did the call. You just observed the cached result. That is the difference between automation that survives a year and automation that breaks every quarter.

What about rate limits and bans?

Reddit watches outbound DM rate, the ratio of new threads vs replies, and the speed/timing of typing. The pattern that does not get flagged in practice is: short bursts (under 10 messages per session), human-shaped intervals (8-25 seconds between sends, not a fixed value), and replies vs cold opens at roughly 4-to-1. The CLI in S4L's repo intentionally does NOT include a 'send 200 DMs in 10 minutes' mode. If you want that, you want a different (and shorter-lived) tool.

Why drive a real browser instead of HTTP requests?

Three reasons. First, Reddit sets multiple correlated cookies (reddit_session, edgebucket, csrf, and the matrix access_token in localStorage) that are easier to keep coherent inside a real Chromium profile than to forge. Second, Reddit Chat is a single-page React app whose actions are wrapped in CSRF-bound mutations; the easiest way to replay them is to let the React app fire them. Third, the Matrix sync state lives in IndexedDB which only exists inside a browser context. A pure HTTP client would have to maintain its own equivalent of matrix-js-sdk locally. Driving Chromium gets you all three for free.

Can I run this on a server with no display?

Yes. The reference uses Playwright's persistent context with headless=true. The trick is the persistent profile directory: install Chromium and Playwright on the server, copy your local profile (or log in once via headed mode with X forwarding), then run headless from then on. The profile carries the Reddit session and the Matrix store. Each run reopens the same on-disk IndexedDB; sync continues from where it left off. Just keep the lock file pattern in place so two CLI invocations cannot fight over the same profile, which corrupts IndexedDB.

How does the dedup work for inbound replies so I do not log the same message twice?

Every Matrix message has a globally-unique event_id. The schema uses that as the dedup key on the dm_messages table. The ingest loop iterates the timeline events in roomsData.join[*].timeline.events, and on insert it ignores rows whose event_id already exists. This means you can run the read pass as often as you want; it is idempotent. The first run after you reply via the CLI will skip your own outbound (sender mxid matches the OUR_MXID), and inbound messages you already saw are silently deduped.

Will this break if Reddit redesigns Chat?

The send leg (typing into the message box, pressing Enter) is selector-based and will need a new selector if Reddit ships a new Chat UI. The read leg is more durable: it depends on the matrix-js-sdk schema (room_id, unread_notifications, timeline.events, m.room.member, m.room.message). That schema has been stable since Reddit Chat shipped because matrix-js-sdk is upstream open source and Reddit consumes it as a library. A redesign of the React shell is unlikely to change the IndexedDB store name or the Matrix event types. If it does, the recovery is one DevTools open + one constant rename.

Want this CLI running on your Reddit account?

Walk me through your account, your DM volume, and what you want the loop to do. I'll tell you whether the Matrix-cache pattern fits and what to wire it into.

s4l.aibooked calls from social
© 2026 s4l.ai. All rights reserved.