Discord Markdown Gotchas for Bot Messages

Discord headings need blank lines before them, markdown links generate embed previews, and messages over 2000 chars get rejected silently.

The Problem

When @Obi started posting formatted morning briefs to Discord — calendar events, task lists, suggested priorities — the messages looked broken. Headings rendered as literal # characters, link previews cluttered the channel with embed cards, and occasionally a brief just vanished entirely with no error in the bot logs.

Three separate issues, all related to Discord’s markdown implementation diverging from standard markdown.

Gotcha 1: Headings Need a Blank Line Before Them

Standard markdown doesn’t require a blank line before a heading. Discord does.

Before — renders as plain text:

Today's tasks:\n## Priority Items\n- Fix the scheduler

What the user sees:

Today's tasks:
## Priority Items
- Fix the scheduler

The ## renders as literal text. No heading formatting.

After — renders as a heading:

Today's tasks:\n\n## Priority Items\n- Fix the scheduler

The fix is a double newline (\n\n) before any heading. This applies to #, ##, and ###.

When your bot sends a message containing markdown links like [Meeting notes](https://docs.google.com/...), Discord auto-generates an embed preview for each URL. A morning brief with three calendar event links suddenly has three large preview cards appended to it, pushing actual content off-screen.

The fix: suppress embeds after sending.

const msg = await channel.send(briefContent);
await msg.suppressEmbeds(true);

Call suppressEmbeds(true) on the message object returned by channel.send(). This removes the embed previews while keeping the clickable markdown links intact.

Gotcha 3: Messages Over 2000 Characters Get Rejected

Discord has a hard 2000-character limit per message. The API returns an error, but if your bot doesn’t handle it, the message just disappears. A detailed morning brief with calendar events, task lists, and suggested priorities easily exceeds 2000 characters.

The fix: split before sending.

async function sendMessage(channel, content) {
  const chunks = splitMessage(content, 2000);
  let lastMsg;
  for (const chunk of chunks) {
    lastMsg = await channel.send(chunk);
    await lastMsg.suppressEmbeds(true);
  }
  return lastMsg;
}

function splitMessage(text, maxLength) {
  if (text.length <= maxLength) return [text];

  const chunks = [];
  let remaining = text;

  while (remaining.length > 0) {
    if (remaining.length <= maxLength) {
      chunks.push(remaining);
      break;
    }

    // Find the last paragraph break before the limit
    let splitIndex = remaining.lastIndexOf('\n\n', maxLength);
    if (splitIndex === -1) {
      // Fall back to last newline
      splitIndex = remaining.lastIndexOf('\n', maxLength);
    }
    if (splitIndex === -1) {
      // Hard split as last resort
      splitIndex = maxLength;
    }

    chunks.push(remaining.slice(0, splitIndex));
    remaining = remaining.slice(splitIndex).trimStart();
  }

  return chunks;
}

Split on paragraph boundaries (\n\n) to keep sections intact. Fall back to single newlines, then hard-split as a last resort. Suppress embeds on every chunk.

The Combined Wrapper

One function that handles all three gotchas:

async function sendFormattedMessage(channel, content) {
  // Fix heading formatting: ensure blank line before headings
  const fixed = content.replace(/([^\n])\n(#{1,3} )/g, '$1\n\n$2');

  // Split and send
  const chunks = splitMessage(fixed, 2000);
  let lastMsg;
  for (const chunk of chunks) {
    lastMsg = await channel.send(chunk);
    await lastMsg.suppressEmbeds(true);
  }
  return lastMsg;
}

Key Takeaway

Discord markdown is not standard markdown. If your bot sends formatted messages — morning briefs, reports, status updates — wrap your send logic in a helper that enforces blank lines before headings, splits on 2000-char boundaries, and suppresses embed previews. You’ll hit all three of these within the first week of building a bot that sends anything more complex than plain text.