How to build a Slack Agent with Mastra

Slack, Mastra , LLMs, and PostgreSQL can power an agent that creates reports, documents, and other files from one message, then updates them in the same thread.
This article uses landing-page generation as a practical example.

Contents

  1. Handle the first Slack message
  1. Connect Slack to Mastra
  1. Keep each project in one thread
  1. Update only the requested parts
  1. Save versions and stop duplicate work

1. Handle the first Slack message

A user starts with a short brief:
@bot Create a landing page for a developer conference. Use a dark theme and the attached poster as a visual reference.
The request follows this path:
Mastra creates the Slack webhook and passes a common thread and message shape to the handler. The handler reads the text, Slack IDs, links, and attached images. It loads the project, calls the LLM, validates the HTML, saves the result, and replies in the same thread.
The LLM receives 2 prompts. The Slack message is the user prompt. A separate system prompt sets the design rules, HTML format, and safety limits. Attached images go to the same model call as visual references.
The preview route is a custom route on the Mastra server. When someone opens the link, the route loads the saved HTML from PostgreSQL. Response headers stop the generated page from submitting forms, opening frames, changing the browser location, or making fetch requests.

2. Connect Slack to Mastra

Mastra ties the agent, Slack channel, storage, and server routes together. This shortened setup uses clearer names for the two handlers and preview routes:
TSX

Loading editor...

import { Agent } from "@mastra/core/agent";
import { Mastra } from "@mastra/core/mastra";
import { PostgresStore } from "@mastra/pg";
import { createSlackAdapter } from "@chat-adapter/slack";
import { LandingPageRepository } from "./db/repositories";

const landingPageAgent = new Agent({
  id: "landing-page-agent",
  name: "Landing Page Agent",
  model: LANDING_PAGE_MODEL_ID,

  // The default Mastra agent path uses these instructions and tools.
  // Our Slack handlers bypass that path and call app services directly.
  // instructions: () => getLandingPageOrchestratorInstructions(),
  // tools: {
  //   get_page_context: getPageContextTool,
  //   publish_landing_page: publishLandingPageTool,
  // },

  channels: {
    adapters: {
      slack: {
        adapter: createSlackAdapter({
          mode: "webhook",
          botToken: () => process.env.SLACK_BOT_TOKEN!,
          webhookVerifier: verifySlackWebhook,
        }),
      },
    },
    handlers: {
      onMention: slackGenerationHandler({ subscribe: true }),
      onSubscribedMessage: slackGenerationHandler({ subscribe: false }),
    },
  },
});

function createDatabase(databaseUrl: string) {
  const storage = new PostgresStore({
    id: "landing-page-postgres",
    connectionString: databaseUrl,
    schemaName: "mastra",
  });

  return { storage, landingPages: new LandingPageRepository(storage.pool) };
}

export const mastra = new Mastra({
  agents: { landingPageAgent },
  storage: createDatabase(process.env.DATABASE_URL).storage,
  server: {
    apiRoutes: [
      latestPagePreviewRoute,
      versionedPagePreviewRoute,
    ],
  },
});
Each part has one job:
Part
Job
Slack
Collect requests and show progress and results
Mastra Channels
Create the webhook, verify events, track threads, and call handlers
Custom handler
Run the generation steps in a fixed order
LLM
Generate the full page or selected sections
PostgreSQL
Store projects, request status, HTML, and page versions
Mastra server routes
Serve the current page and older versions
Mastra agents handle open-ended work by choosing tools based on their instructions. Our Slack flow has a fixed order, so its handlers call app services directly. The code loads the project, validates the HTML, saves it, and then posts to Slack.
The instructions and tools fields support other Mastra entry points. The custom Slack handlers do not use them. A longer fixed process could move into a Mastra Workflow.
The first mention runs onMention and subscribes the bot to the thread. Later replies can reach onSubscribedMessage.
The reply handler uses { subscribe: false } because the first mention already subscribed the thread. Filter unmentioned replies in the handler:
TSX

Loading editor...

if (!message.isMention) return;
The guard lets teammates discuss the page and mention @bot only when they want a new version. Slack reactions can show โณ while the bot works, โœ… on success, and โŒ on failure.

3. Keep each project in one thread

Use the Slack thread ID as the project key. The first message creates the project. Each later request loads the same project and current page.
Mastra stores the thread subscription. PostgreSQL stores the HTML, current version, older versions, model ID, and prompt version.
The default Mastra agent path can use thread history and memory. Our handler loads the exact page from PostgreSQL instead. If an update depends on older messages, pass them through Mastra memory as extra LLM context.

4. Update only the requested parts of the page

You can regenerate the full page or update selected sections.
Mode
Best for
Model input
Main trade-off
Full page
New layout or full redesign
Current HTML and new request
May change text or styles the user did not mention
Local update
Copy, links, or styles in named sections
Selected sections and new request
Needs stable section IDs and merge checks
A local update runs 5 steps:
  1. Add stable IDs to page sections.
  1. Ask one LLM call which sections need changes.
  1. Ask a second call to rewrite those sections.
  1. Merge the new sections into the current HTML.
  1. Validate the page and check that other sections stayed the same.
This keeps approved copy and layout outside the target sections. It also sends less HTML to the generation call and makes the final check clear: non-target sections must not change.
If the request is unclear, keep the current version and ask the user to name the section or change.

5. Save versions and stop duplicate messages

After each successful update, insert a new version and move the project's current-version pointer in one PostgreSQL transaction. If generation fails, the last working version stays current.
Slack may send the same event more than once. Build an idempotency key from the Slack thread ID and message ID, then claim it before the LLM call. A retry can return the saved result or stop while the first request still runs.
The preview routes read the same PostgreSQL records. example.com/p/somePageId opens the current version. example.com/p/somePageId/v/3 always opens version 3.

That's it!
Now you have an agent that remembers the thread, saves every version, and edits only what's asked. Point it at reports, specs, or anything else your team builds together in Slack.

Ship faster than
your competition

Focus on customers and sales while we handle product delivery. Hire a dedicated AI maker or a whole product team.

Launch new products
Fix your delivery
Hit fundraising milestones
[Analytics Chart]
Wireframe v0.1
User
Sarah Chen CEO, Founder
$127k
MRR
2,847
Customers
+23%
Growth
Sales Performance
Latest Deal
Customer
Enterprise plan closed
Acme Corp ยท $12k/mo
ยฉ 2026 Paralect, Inc 651 N Broad St, Suite 206, Middletown, 19709, Delaware, United States