DEV Community

Cover image for How I Published My Own App in the Slack Marketplace (and What the Docs Didn't Tell Me)
Kamil Buksakowski
Kamil Buksakowski

Posted on

How I Published My Own App in the Slack Marketplace (and What the Docs Didn't Tell Me)

Everything I learned while building and publishing a Slack Marketplace app — from OAuth to a four-month review process.

I had a simple problem: long Slack threads and the constant need to manually remove personal information before pasting conversations into ChatGPT.

I made the first commit in October 2025. By May 2026, the app was publicly available in the Slack Marketplace. A small project: around 2,600 lines of TypeScript and a dollar a month for hosting.

Below is the whole process — from the first commit to publication: what worked, what surprised me during review, and what I'd do differently today.


Problem

I use Slack every day, and threads there can grow to absurd sizes. A message with 40, sometimes over 100 replies stops being a conversation and turns into a task you have to work through. Especially when someone tags you in a thread like that for the first time!

I thought: what if I just pasted it into ChatGPT and asked for a summary? Except pasting an internal conversation along with your coworkers' names into AI is a bad idea. So every time, I cleaned the text up by hand.

The idea

The problem kept coming back, so I finally started thinking about a better workflow. The core problem was simple: manually cleaning up every conversation before pasting it into an AI tool.

I ended up building a small Slack app that turns a thread into an AI-ready format with basic anonymization. The easiest way to show it is on a single message. Here's what a message looks like in Slack:

You copy the message link (right-click → Copy link) and run the command:

/copy-thread <pasted link>
Enter fullscreen mode Exit fullscreen mode

In response, you get a modal with the formatted text:

The author's name became User_1, the @John mention became @User, and @here is gone. In longer threads, the User_1, User_2 labels stay consistent across the whole conversation, so you don't lose track of who's talking to whom.

Let me be explicit, because this matters: message authors and mentions get anonymized, not names typed inside the text. If someone wrote "sort this out with Anna from legal" in a message, that stays. This tool takes the tedious work off your hands; it doesn't excuse you from thinking about what you paste.


What you need — the bird's eye view

The whole path in one place:

I know the whole publishing process now, so here's a list of what's along the way:

  1. Requirements and project context — a PRD.md file that doubles as context for AI
  2. The Slack manifest — the heart of the app configuration
  3. Backend and command handling
  4. Local development environment
  5. Deployment and database
  6. Landing page, domain, and Marketplace materials: logo, privacy policy, screenshots, optionally a video
  7. Review and publication

Step 1: Repository and requirements

I started the project by creating a repository on GitHub. After git init, the first file I committed was a simple PRD.md (Product Requirements Document). That's the reference point for Claude or any other AI.

I always make a STATUS.md too, where the AI and I keep track of progress and whatever we're currently working on. This is a small project, so two files describing the product plus one context file for Claude Code were enough:

PRD.md      → what we're building and why
STATUS.md   → where we are
CLAUDE.md   → standard context file for working with Claude Code
Enter fullscreen mode Exit fullscreen mode

Step 2: Creating the app on Slack's side

With a repository and a rough outline of the idea, you can create the app on Slack's side. You don't need to be sure of everything — the idea will take shape as you build.

  1. Go to https://slack.com/marketplace and log into your Slack account
  2. Click Build

  1. Click Create New App and choose From a manifest

I recommend using a manifest. I knew my app would run on a slash command, so I put it there right away.

The manifest is convenient because you control most of the configuration from a single YAML or JSON file. Here's my manifest, trimmed down for this article:

display_information:
  name: Thread to LLM
  description: Copy & anonymize Slack threads for ChatGPT/Claude
  background_color: "#4a154b"
  long_description: "Turn any Slack conversation into clean, AI-ready
    format in seconds. (...)"

features:
  app_home:
    home_tab_enabled: false
    messages_tab_enabled: true
    messages_tab_read_only_enabled: true
  bot_user:
    display_name: ThreadBot
    always_online: true
  slash_commands:
    - command: /copy-thread
      url: https://threadllm.ai/slack/events
      description: Copy a thread formatted for LLMs
      usage_hint: "[thread link]"
      should_escape: true

oauth_config:
  redirect_urls:
    - https://threadllm.ai/slack/oauth_redirect
  scopes:
    bot:
      - channels:history
      - channels:join
      - chat:write
      - commands
      - groups:history

settings:
  event_subscriptions:
    request_url: https://threadllm.ai/slack/events
    bot_events:
      - app_uninstalled
      - tokens_revoked
  interactivity:
    is_enabled: true
    request_url: https://threadllm.ai/slack/events
  socket_mode_enabled: false
  token_rotation_enabled: false
Enter fullscreen mode Exit fullscreen mode

A few things worth paying attention to:

  • I cut long_description short — mine is a dozen or so lines, because that's exactly the text that lands on the app card in the Marketplace. Worth polishing, because that's what sells the app.
  • Don't grab scopes "just in case." During review, Slack asks what you need each permission for — the shorter the list, the less explaining. I ended up with five: public and private channel history, joining channels, posting messages, and handling slash commands.
  • interactivity has to be enabled to open a modal. The slash command alone is enough to receive the command, but views.open won't work without it — the command runs and nothing shows up.
  • app_uninstalled and tokens_revoked are events that are easy to miss but worth handling — otherwise you keep dead tokens in your database after someone uninstalls the app.
  • messages_tab_enabled turns on the messages tab in App Home — that's where the user sees the conversation with the bot. I left read_only at true: the bot writes, but you don't hold a conversation with it.

You'll find Slack's official samples here: https://docs.slack.dev/samples


Step 3: Connecting the Slack app to a backend

Before we get to the code — the whole app is basically one flow:

/copy-thread <link>
        ↓
Slack — signed POST
        ↓
Backend on Railway (@slack/bolt)
        ↓
Slack Web API — conversations.replies
        ↓
Anonymization and markdown formatting
        ↓
Modal with the formatted text
Enter fullscreen mode Exit fullscreen mode

The app is already configured in Slack, so it's time to hook up the backend. I'm comfortable in JavaScript, so I went with @slack/bolt (repo: https://github.com/slackapi/bolt-js). Slack also offers SDKs for other languages, including Python (https://docs.slack.dev/tools/bolt-python) and Java.

You need three things to connect: Client ID, Client Secret, and Signing Secret. You'll find them in the app dashboard, under Basic Information:

Then you pass them into the @slack/bolt configuration:

stateSecret signs and verifies the state parameter in the OAuth process — it protects the install flow against CSRF attacks, among other things.

All the logic happens on a single command — /copy-thread, exactly the one I declared in the manifest earlier. Here's the handler skeleton, without error handling and edge cases:

app.command("/copy-thread", async ({ command, ack, client, logger }) => {
  await ack();

  // Rate limit: 10 commands per minute per user
  const { allowed } = checkRateLimit(command.team_id, command.user_id);
  if (!allowed) return sendEphemeral(command, "⏱️ Slow down!");

  // Validate the Slack message URL
  const urlValidation = parseSlackUrl(command.text.trim());
  if (!urlValidation.valid) {
    return sendEphemeral(command, "❌ That's not a valid message link");
  }
  const { channelId, timestamp } = urlValidation.data;

  // Join public channels automatically (fails silently for private ones)
  try {
    await client.conversations.join({ channel: channelId });
  } catch (joinError) {
    logger.debug(`Could not join ${channelId}`);
  }

  // Fetch the thread and anonymize its content
  const result = await client.conversations.replies({
    channel: channelId,
    ts: timestamp,
    inclusive: true,
  });
  const markdown = formatToMarkdown(result.messages);

  // Show the result in a modal — double-click to select, then copy
  await client.views.open({
    trigger_id: command.trigger_id,
    view: buildResultModal(markdown, result.messages.length),
  });
});
Enter fullscreen mode Exit fullscreen mode

The production version is several times longer, and most of the extra code handles situations I didn't anticipate at first: help, the modal length limit, a friendly message on not_in_channel with /invite @ThreadBot instructions. Some of those are Slack requirements I only found out about while reading the guidelines.

One thing you can't see in this skeleton that can bite you in production: the trigger_id needed to open a modal expires after three seconds. If you squeeze several Slack API calls and a long thread between ack() and views.open(), you might not make it in time. The safer pattern is to open the modal right away and update it once the data is ready.

The backend ended up at around 2,600 lines of TypeScript, and a significant part of that code handles validation, error handling, and security.


Step 4: A separate DEV app and local setup

This is the step I skipped initially — and the one I regretted skipping the most. If you have an app in the Marketplace, every change tested "in production" touches real users. The fix: a second Slack app, purely for development.

Here's what mine looks like:

  • A separate Thread to LLM DEV app with its own manifest (you can see it in the app list screenshot above)
  • A separate test workspace — don't test in the workspace you actually work in
  • Local Postgres in Docker instead of the production database
  • ngrok as an HTTPS tunnel, because Slack needs somewhere to send requests and OAuth callbacks
docker compose up -d    # local Postgres
ngrok http 3000         # terminal 1 — HTTPS tunnel
npm run dev             # terminal 2 — server with .env.development
Enter fullscreen mode Exit fullscreen mode

And now two things that cost me time, which someone could have told me earlier:

ngrok on the free plan changes the URL on every restart. That sounds like a small detail until you realize the new address has to be swapped in five places: BASE_URL in .env.development plus four manifest fields (slash_commands[].url, oauth_config.redirect_urls[], event_subscriptions.request_url, interactivity.request_url).

The "Install to Workspace" button in the Slack dashboard skips your own OAuth flow. That's a developer install — you get a token, the app works, but your storeInstallation handler never fires and nothing lands in the database. I spent a while wondering why the installations table was empty. To test the real installation path, you have to open <ngrok-url>/install in a browser and go through OAuth the way a user will.


Step 5: Deploying the backend

To properly test the Slack → backend flow, the app needs to be hosted somewhere. I picked Railway because deployment is simple and cheap. Simple, because you just import the project from GitHub. Cheap, because I pay about a dollar a month.

My app only needed a backend and a database:

The .env file wasn't particularly large either:

One variable deserves a comment: ENCRYPTION_KEY. The bot token you get on installation gives access to the user's workspace within the permissions granted to the app. Keeping it in the database as plain text would be an unnecessary risk, so my tokens land there encrypted with AES-256-GCM. You generate the key itself with one command:

openssl rand -hex 32
Enter fullscreen mode Exit fullscreen mode

That pays off later, when filling out Slack's form about data handling — it's much easier to answer security questions when you can point to a specific solution instead of writing generalities.

At this point I already had:

  1. A Slack app
  2. A backend connected to the Slack app

Basically a complete setup for testing. I didn't have a website yet, so I installed the app directly through the install URL.

To make sure everything worked correctly, I tested the app on a real thread — five messages, two people talking, labels consistent across the whole conversation:


Step 6: Landing page and domain

After testing, the app behaved exactly the way I wanted. So I figured that since Railway is running anyway, I'd use it to serve static HTML with an install button:

For publication I also needed a custom domain — Slack asked me to confirm that the app's website actually belongs to me. I picked Cloudflare, to avoid vendor lock-in and keep DNS in one place.


Step 7: Getting ready to publish

Testing done, one last thing left: getting ready to submit the app for review. That took a few pieces:

  • Logo — I generated it with OpenAI's DALL·E
  • Privacy policy and support page — this is where the domain came in handy
  • Slack's form about how you use data — extensive, worth setting aside some time for it
  • Support email — in Cloudflare I set up forwarding from the domain address to my private inbox
  • Slack Marketplace description plus screenshots showing the app in action
  • Video demo — optional, but I made one and included the link, and also put it in the app's welcome message

One thing I didn't think about until I was staring at an empty "screenshots" field: you can't take a screenshot of a real company conversation. Names, context, sometimes client data — none of that can go on a public listing.

I solved it by writing a few thread scenarios: fictional people, but a realistic product discussion (mine included an onboarding redesign and an integration with an external system). I recreated them in the test workspace and took the screenshots from that.


Step 8: Submitting for review

The last and most important step.

I have one piece of advice here: don't wait for the perfect version of the app, submit as early as you can. I submitted at the beginning of January and got approval in May — so the review alone took four months, and the months before that, counting from the first commit, went to development and prep. Instead of polishing the product, it's better to send an imperfect version — let it sit in the queue while you keep working on it anyway.

With one caveat: that applies to the app itself. Polish the listing, description, and pages before submitting — Slack states plainly in the confirmation email that at this stage every fix resets your place in the queue.

What surprised me along the way

The most important traps from the whole process, collected in one place — a few of them showed up above, but this is the list I wish I'd had at the start:

  • help is mandatory. The slash command has to respond sensibly when someone types help or pastes nonsense. That's spelled out in Slack's requirements.
  • A modal has a 3,000 character limit per text block. Longer threads have to go another way — mine go out as an ephemeral message.
  • Slack asks about every scope separately. There's no "I took it just in case" option.
  • The "Install to Workspace" button in the Slack dashboard skips your OAuth flow. The app works, but your storeInstallation never fires and the database stays empty.
  • You can't take screenshots from real conversations. You have to stage them.
  • Not every change requires another review. In my case, a feature based on an already approved scope could be deployed without going back into the queue. More on that below, because it's the most useful thing on this list.

After the Slack team approves it, you click Publish and you can search for your own app in the Slack Marketplace. After months of waiting for that one answer, seeing your own icon in Slack's search is surprisingly satisfying.


What I learned

Submit for review earlier. If I were doing this again, that's the one thing I'd change for sure. Four months in the review queue pass either way, whether your app is perfectly polished or simply good enough to ship — and you'll refine the product in the meantime anyway.

The scope of the change decides whether you wait. I was adding a welcome message sent after installation and bracing for another wait — needlessly. The feature fit within chat:write, a permission I already had approved, so it went out as a regular deploy.

If a change required a new scope, it could mean going back through verification. In my case, later reviews went faster than the first entry into the Marketplace, but I wouldn't treat that as a guaranteed rule. It's simply worth checking the scope of the change before you shelve a feature "for later" out of fear of the queue.

The name affects discoverability; the domain affects credibility. A good name helps people find the app in the Marketplace — mine seems to work reasonably well, since I still get a few installs every month. A custom domain does something different: it makes the landing page, privacy policy, and support address feel like parts of a real product rather than a weekend experiment. That matters both during review and when a user first encounters the product.

Retention is the weaker part — users leave. That's exactly why the welcome message with a tutorial link exists, but that probably isn't enough. Maybe an AI summary?


The whole project in one table


Wrapping up

What surprised me most is that building the app turned out to be easier than publishing it. The code came together relatively quickly. Everything around it ate the most time: review, documentation, privacy policy, forms, screenshots, description, domain.

Even so, it was definitely worth it — and it's not even about the installs. It's about actually taking a project all the way through instead of leaving yet another side project half-finished.

The end result is available in the Slack Marketplace as Thread to LLM — if you'd like to see how it turned out, you can find it here.

Top comments (0)