Skip to content

Blog

Create a Multi-Agent Discord News Bot with Mastra (Complete Guide 2025)

A Discord news bot

With the rise of LLMs, agents, MCP, and other technologies, building applications has become easier than ever. Today, I will introduce you to an agentic framework in TypeScript that I really enjoy: Mastra. https://mastra.ai/

Mastra

The goal here is not to present all the features of Mastra or go into configuration details, but to show you its potential with workflows and how easy it is to get started.

Normally, there is a playground that makes testing easier, but here we will do everything directly on Discord.

Discord

First, go to: https://discord.com/developers/applications

And create a new application

Discord

In the ‘General Information’ section, retrieve the application ID for later.

Then, in the bot section, reset the token to generate a new one.

Finally, in the installation section, get the installation URL to add it to a server for testing.

CODE!

Now let’s move on to the code and project setup.

Setup

To set up the project in TypeScript, I will follow this guide:

https://medium.com/@rosarioborgesi/setting-up-a-node-js-project-with-pnpm-and-typescript-0b16a512ef24

Only my scripts section is different :

"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
}

For this, you will also need to add tsx :

Fenêtre de terminal
pnpm add tsx

Now let’s set up our Discord bot, a simple echo message for now.

You will need to install the Discord.js library

Fenêtre de terminal
pnpm add discord.js dotenv

Here is my index.ts :

import { Client, GatewayIntentBits, REST, Routes, SlashCommandBuilder } from 'discord.js';
import dotenv from 'dotenv';
// Load environment variables
dotenv.config();
// Create a new instance of the Discord client
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
],
});
// Define the /ping command
const commands = [
new SlashCommandBuilder()
.setName('ping')
.setDescription('Replies with pong!')
.toJSON(),
];
// Function to register slash commands
async function deployCommands() {
const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_TOKEN!);
try {
console.log('Deploying slash commands...');
await rest.put(
Routes.applicationCommands(process.env.CLIENT_ID!),
{ body: commands },
);
console.log('Slash commands deployed successfully!');
} catch (error) {
console.error('Error deploying commands:', error);
}
}
// Event: Bot ready
client.once('ready', async () => {
console.log(`Bot connected as ${client.user?.tag}!`);
await deployCommands();
});
// Event: Interaction with slash commands
client.on('interactionCreate', async (interaction) => {
if (!interaction.isChatInputCommand()) return;
const { commandName } = interaction;
if (commandName === 'ping') {
await interaction.reply('Pong!');
}
});
// Error handling
client.on('error', (error) => {
console.error('Discord client error:', error);
});
process.on('unhandledRejection', (error) => {
console.error('Unhandled promise rejection:', error);
});
// Bot connection
client.login(process.env.DISCORD_TOKEN);

Run pnpm run dev and add it to your server, it should respond pong to you !

pong

Mastra

For this tutorial, I will use Google keys and the flash preview model, as it is free !

You could also use them to have a 100% free bot : https://ai.google.dev/gemini-api/docs/api-key?hl=fr

First, we will install all the dependencies :

Fenêtre de terminal
pnpm add @mastra/core @mastra/memory @mastra/libsql @ai-sdk/google zod rss-parser

Now we will create an agents folder and make an agentRss.ts :

import 'dotenv/config';
import { Agent } from "@mastra/core/agent";
import { Memory } from '@mastra/memory';
import { LibSQLStore } from '@mastra/libsql';
import { createTool } from '@mastra/core';
import { z } from 'zod';
import Parser from 'rss-parser';
import { google } from '@ai-sdk/google';
// Initialize the RSS parser
const parser = new Parser();
const memory = new Memory({
storage: new LibSQLStore({
url: "file:./memory.db",
}),
});
const today = new Date().toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long'
});
// Tool to read RSS feeds
const rssFeedTool = createTool({
id: 'rss-feed-reader',
description: 'Reads and analyzes an RSS feed from a URL',
inputSchema: z.object({
url: z.string().url().describe('The URL of the RSS feed to read'),
limit: z.number().optional().default(10).describe('Maximum number of articles to retrieve (default: 10)')
}),
outputSchema: z.object({
title: z.string().describe('Title of the RSS feed'),
description: z.string().optional().describe('Description of the feed'),
link: z.string().optional().describe('Link to the website'),
articles: z.array(z.object({
title: z.string().describe('Title of the article'),
link: z.string().optional().describe('Link to the article'),
pubDate: z.string().optional().describe('Publication date'),
contentSnippet: z.string().optional().describe('Content snippet'),
creator: z.string().optional().describe('Author of the article')
})).describe('List of articles in the feed')
}),
execute: async ({ context }) => {
try {
const { url, limit } = context;
// Parse the RSS feed
const feed = await parser.parseURL(url);
// Limit the number of articles
const articles = feed.items.slice(0, limit).map(item => ({
title: item.title || 'Untitled',
link: item.link || undefined,
pubDate: item.pubDate || undefined,
contentSnippet: item.contentSnippet || item.content || undefined,
creator: item.creator || item.author || undefined
}));
return {
title: feed.title || 'RSS Feed',
description: feed.description || undefined,
link: feed.link || undefined,
articles
};
} catch (error) {
throw new Error(`Error reading RSS feed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
});
export const rssAgent = new Agent({
name: 'RSS Feed Agent',
instructions:
`You are a specialized RSS feed reader assistant. ` +
`Today is ${today}. ` +
`Your capabilities include: ` +
`- Reading and parsing RSS feeds from URLs ` +
`- Summarizing RSS feed content and articles ` +
`- Providing information about the latest articles from various sources ` +
`- Helping users stay updated with news and content from their favorite websites ` +
`When users provide RSS feed URLs or ask about specific feeds: ` +
`- Use the rssFeedTool to fetch and parse the RSS content ` +
`- Provide clear summaries of the articles ` +
`- Highlight the most important or recent articles ` +
`- Format the information in an easy-to-read way ` +
`You can help with popular RSS feeds from tech blogs, news sites, ` +
`podcasts, and other content sources. Always be helpful and provide ` +
`organized information about the RSS content.`,
model: google('gemini-2.5-flash-preview-04-17'),
memory,
tools: { rssFeedTool }
});

Here we have created an agent with the current date and it has a tool that allows it to retrieve RSS feeds using a URL. In the .env file, you must also add GOOGLE_GENERATIVE_AI_API_KEY=your-key, for it to work.

Now we will create an agent that will rewrite the news received.

agentWriter.ts

import 'dotenv/config';
import { Agent } from "@mastra/core/agent";
import { Memory } from '@mastra/memory';
import { google } from '@ai-sdk/google';
import { LibSQLStore } from '@mastra/libsql';
const memory = new Memory({
storage: new LibSQLStore({
url: "file:./memory.db",
}),
});
export const writerAgent = new Agent({
name: 'Content Writer',
instructions: `You are an expert content rewriter specialized in tech news and research aggregation. Your goal is to:
1. Take multiple pieces of content (RSS feeds, web research, etc.) and create a comprehensive digest
2. Identify common trends and important topics across sources
3. Organize the information with clear sections
4. Maintain accuracy while improving readability and flow
5. Respond in plain text with no Markdown formatting
When rewriting:
- Prioritize the most important and current information
- Group related topics together
- Highlight major trends and innovations
- Use a professional yet accessible tone
- Include dates and sources when available
- Do NOT use Markdown; answer as if sending a simple text message
`,
memory,
model: google('gemini-2.5-flash-preview-04-17')
});

And now let’s move on to the workflow ! Where we will specify which URLs to check. We could go further and create a command for the user to add sources and during the workflow we would check the sources in the database.

but for now let’s keep it simple.

Create a workflows folder and inside create an index.ts

import 'dotenv/config';
import { createWorkflow, createStep } from "@mastra/core/workflows";
import { z } from "zod";
import { rssAgent } from "../agents/agentRss";
import { writerAgent } from "../agents/agentWriter";
// Step 1: Read Blog du Modérateur Tech
const blogModeratorStep = createStep({
id: "blog-moderator-step",
description: "Reads the RSS feed from Blog du Modérateur Tech",
inputSchema: z.object({}),
outputSchema: z.object({
blogModeratorContent: z.string()
}),
execute: async () => {
const message1 = "📡 Step 1: Reading RSS feed from Blog du Modérateur Tech...";
console.log(message1);
const response = await rssAgent.generate(
"Read the RSS feed from https://www.blogdumoderateur.com/tech/feed/ and give me a CONCISE summary of the 3 most recent articles only. Limit yourself to 2-3 sentences per article.",
{
resourceId: "rss_workflow",
threadId: "rss_workflow_blog_moderator"
}
);
const message2 = "✅ Step 1: Blog du Modérateur Tech completed";
console.log(message2);
return {
blogModeratorContent: response.text
};
}
});
// Step 2: Read Le Monde Informatique Cloud
const mondeInformatiqueStep = createStep({
id: "monde-informatique-step",
description: "Reads the RSS feed from Le Monde Informatique Cloud",
inputSchema: z.object({}),
outputSchema: z.object({
mondeInformatiqueContent: z.string()
}),
execute: async () => {
const message1 = "📡 Step 2: Reading RSS feed from Le Monde Informatique Cloud...";
console.log(message1);
const response = await rssAgent.generate(
"Read the RSS feed from https://www.lemondeinformatique.fr/flux-rss/thematique/le-monde-du-cloud-computing/rss.xml and give me a CONCISE summary of the 3 most recent articles on cloud computing. Limit yourself to 2-3 sentences per article.",
{
resourceId: "rss_workflow",
threadId: "rss_workflow_monde_informatique"
}
);
const message2 = "✅ Step 2: Le Monde Informatique Cloud completed";
console.log(message2);
return {
mondeInformatiqueContent: response.text
};
}
});
// Step 3: Read Forbes Innovation
const forbesStep = createStep({
id: "forbes-step",
description: "Reads the RSS feed from Forbes Innovation",
inputSchema: z.object({}),
outputSchema: z.object({
forbesContent: z.string()
}),
execute: async () => {
const message1 = "📡 Step 3: Reading RSS feed from Forbes Innovation...";
console.log(message1);
const response = await rssAgent.generate(
"Read the RSS feed from https://www.forbes.com/innovation/feed and give me a CONCISE summary of the 3 most recent articles on innovation. Limit yourself to 2-3 sentences per article.",
{
resourceId: "rss_workflow",
threadId: "rss_workflow_forbes"
}
);
const message2 = "✅ Step 3: Forbes Innovation completed";
console.log(message2);
return {
forbesContent: response.text
};
}
});
// Step 4: Rewrite and final synthesis
const rewriteStep = createStep({
id: "rewrite-synthesis-step",
description: "Rewrites and synthesizes all RSS content",
inputSchema: z.object({
"blog-moderator-step": z.object({ blogModeratorContent: z.string() }),
"monde-informatique-step": z.object({ mondeInformatiqueContent: z.string() }),
"forbes-step": z.object({ forbesContent: z.string() })
}),
outputSchema: z.object({
finalDigest: z.string()
}),
execute: async ({ inputData }) => {
const message1 = "🔄 Step 4: Synthesis and rewriting...";
console.log(message1);
const blogModeratorContent = inputData["blog-moderator-step"].blogModeratorContent;
const mondeInformatiqueContent = inputData["monde-informatique-step"].mondeInformatiqueContent;
const forbesContent = inputData["forbes-step"].forbesContent;
const synthesisPrompt = `
Create a professional daily tech digest from the following RSS content:
Source 1 - Blog du Modérateur Tech:
${blogModeratorContent}
Source 2 - Le Monde Informatique (Cloud):
${mondeInformatiqueContent}
Source 3 - Forbes Innovation:
${forbesContent}
Instructions:
1. Create an organized synthesis by technological themes
2. Identify common trends and important topics
3. Prioritize information by importance
4. Use plain text without Markdown, like a WhatsApp message
5. Always indicate the source and date for each article
6. Keep an informative and engaging tone with clear sections
`;
const response = await writerAgent.generate([
{ role: "user", content: synthesisPrompt }
]);
const message2 = "✅ Step 4: Final synthesis completed, coming soon!";
console.log(message2);
return {
finalDigest: response.text
};
}
});
// Create the complete workflow
export const rssWorkflow = createWorkflow({
id: "rss-aggregator-workflow",
description: "Aggregates and rewrites content from multiple tech RSS sources",
inputSchema: z.object({}),
outputSchema: z.object({
finalDigest: z.string()
})
})
.parallel([
blogModeratorStep,
mondeInformatiqueStep,
forbesStep
])
.then(rewriteStep)
.commit();

Very well we have a workflow now ! Which has three steps that are done in parallel (fetching RSS and synthesizing by LLM) and then the ‘writer’ agent summarizes the news.

You might say very well but I want to chat with me, no problem !

Master Agent

We will soon test our code, but before that, we have a few steps left.

Create a agentMaster.ts in the agents folder :

import 'dotenv/config';
import { Agent } from "@mastra/core/agent";
import { Memory } from '@mastra/memory';
import { rssWorkflow } from '../workflows';
import { google } from '@ai-sdk/google';
import { LibSQLStore } from '@mastra/libsql';
const memory = new Memory({
storage: new LibSQLStore({
url: "file:./memory.db",
}),
});
const today = new Date().toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long'
});
export const chefAgent = new Agent({
name: 'News Assistant',
instructions:
`You are a specialized news assistant focused on providing tech news and current events. ` +
`Today is ${today}. ` +
`Always reply in plain text without using Markdown formatting. ` +
`Do not use asterisks or any other symbols for bold or italic text. ` +
`MANDATORY RULE: For ANY request related to news, actualités, current events, or tech updates, ` +
`you MUST use the rssWorkflow immediately. This includes: ` +
`- "actualités" ` +
`- "news" ` +
`- "dernières nouvelles" ` +
`- "infos du jour" ` +
`- "tech news" ` +
`- "what's happening" ` +
`- Any request for current information ` +
`The rssWorkflow provides real-time tech news from Blog du Modérateur, Le Monde Informatique, and Forbes Innovation. ` +
`Always use rssWorkflow first for news requests - do not attempt to provide news from your knowledge base. `,
model: google('gemini-2.5-flash-preview-04-17'),
memory,
workflows: { rssWorkflow },
});

And in this agent, we have given the workflow as a tool, so it can execute the workflow to retrieve the news. Great right ?!

Discord Test

Let’s modify the index.ts to add the /chat command and generate a conversation with the master agent :

import { Client, GatewayIntentBits, REST, Routes, SlashCommandBuilder } from 'discord.js';
import dotenv from 'dotenv';
import { chefAgent } from './agents/agentMaster';
// Load environment variables
dotenv.config();
// Create a new instance of the Discord client
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
],
});
// Define the /ping and /chat commands
const commands = [
new SlashCommandBuilder()
.setName('ping')
.setDescription('Replies with pong!')
.toJSON(),
new SlashCommandBuilder()
.setName('chat')
.setDescription('Chat with the AI assistant')
.addStringOption(option =>
option.setName('message')
.setDescription('Your message to the assistant')
.setRequired(true))
.toJSON(),
];
// Function to register slash commands
async function deployCommands() {
const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_TOKEN!);
try {
console.log('Deploying slash commands...');
await rest.put(
Routes.applicationCommands(process.env.CLIENT_ID!),
{ body: commands },
);
console.log('Slash commands deployed successfully!');
} catch (error) {
console.error('Error deploying commands:', error);
}
}
// Event: Bot ready
client.once('ready', async () => {
console.log(`Bot connected as ${client.user?.tag}!`);
await deployCommands();
});
// Event: Interaction with slash commands
client.on('interactionCreate', async (interaction) => {
if (!interaction.isChatInputCommand()) return;
const { commandName } = interaction;
if (commandName === 'ping') {
await interaction.reply('Pong!');
} else if (commandName === 'chat') {
const message = interaction.options.getString('message', true);
try {
// Respond immediately to avoid timeout
await interaction.reply('🤖 Searching for information...');
// Create a unique threadId based on user and date
const uniqueThreadId = `discord_chat_${interaction.user.id}_${new Date().toDateString().replace(/\s/g, '_')}`;
// Send the message to the agent
const response = await chefAgent.generate(message, {
resourceId: `discord_user_${interaction.user.id}`,
threadId: uniqueThreadId,
});
// Function to split text into chunks of max 2000 characters
const splitMessage = (text: string, maxLength: number = 2000): string[] => {
if (text.length <= maxLength) {
return [text];
}
const chunks: string[] = [];
let currentChunk = '';
// Split by paragraphs first
const paragraphs = text.split('\n\n');
for (const paragraph of paragraphs) {
// If the paragraph alone exceeds the limit, split by sentences
if (paragraph.length > maxLength) {
const sentences = paragraph.split('. ');
for (const sentence of sentences) {
const potentialChunk = currentChunk + (currentChunk ? '\n\n' : '') + sentence + (sentence.endsWith('.') ? '' : '.');
if (potentialChunk.length > maxLength) {
if (currentChunk) {
chunks.push(currentChunk.trim());
currentChunk = sentence + (sentence.endsWith('.') ? '' : '.');
} else {
// If even a sentence exceeds, cut it brutally
chunks.push(sentence.substring(0, maxLength - 3) + '...');
}
} else {
currentChunk = potentialChunk;
}
}
} else {
const potentialChunk = currentChunk + (currentChunk ? '\n\n' : '') + paragraph;
if (potentialChunk.length > maxLength) {
chunks.push(currentChunk.trim());
currentChunk = paragraph;
} else {
currentChunk = potentialChunk;
}
}
}
if (currentChunk) {
chunks.push(currentChunk.trim());
}
return chunks;
};
// Split the response if necessary
const messageChunks = splitMessage(response.text);
// Send the first chunk by modifying the initial response
await interaction.editReply(messageChunks[0]);
// Send the following chunks as new messages
for (let i = 1; i < messageChunks.length; i++) {
await interaction.followUp(messageChunks[i]);
}
} catch (error) {
console.error('Error generating response:', error);
await interaction.editReply('Sorry, an error occurred while processing your request. 😔');
}
}
});
// Error handling
client.on('error', (error) => {
console.error('Discord client error:', error);
});
process.on('unhandledRejection', (error) => {
console.error('Unhandled promise rejection:', error);
});
// Bot connection
client.login(process.env.DISCORD_TOKEN);

Now do /chat and ask “What are the news of the day ? ”

actuResponse

Isn’t it great ?

Let’s add a web workflow to ask for more information about a news !

Web

To avoid recreating an agent, you can use https://tsai-registry.dev.

I will take this opportunity to add the web agent, before that, go to create a firecrawl.dev account and retrieve your free API key.

Run the following command to add your web agent :

Fenêtre de terminal
npx tsai-registry add firecrawl-agent

The utility will ask you for your firecrawl API key to feed the .env directly.

Once installed, you should have a mastra/registry/agent/firecrawl… folder, I will take the firecrawl folder and put it in my src/agents.

I will not go into detail about the agent configuration, all you have to do is replace openai with google like the other agents.

Then, you can create a web.ts in the workflows folder

import 'dotenv/config';
import { createWorkflow, createStep } from "@mastra/core/workflows";
import { z } from "zod";
import { firecrawlAgent } from "../agents/firecrawl-agent/index";
import { writerAgent } from "../agents/agentWriter";
// Step 1: Web search
const webSearchStep = createStep({
id: "web-search-step",
description: "Performs a web search on the given topic",
inputSchema: z.object({
query: z.string().describe("The topic or search query")
}),
outputSchema: z.object({
searchResults: z.string().describe("The compiled search results")
}),
execute: async ({ inputData }) => {
const { query } = inputData;
const message1 = "🔎 Step 1: Web search in progress...";
console.log(message1);
const prompt = `Search for detailed information on: ${query}`;
const { text } = await firecrawlAgent.generate([
{ role: "user", content: prompt }
]);
const message2 = "✅ Step 1: Web search completed.";
console.log(message2);
return {
searchResults: text
};
}
});
// Step 2: Rewrite content
const rewriteStep = createStep({
id: "rewrite-step",
description: "Rewrites and improves the search content",
inputSchema: z.object({
searchResults: z.string().describe("The search results to rewrite")
}),
outputSchema: z.object({
rewrittenContent: z.string().describe("The rewritten and improved content")
}),
execute: async ({ inputData }) => {
const { searchResults } = inputData;
const message1 = "✍️ Step 2: Rewriting content in progress...";
console.log(message1);
const prompt = `
Rewrite and improve the following content in a clear and engaging way:
${searchResults}
Instructions:
- Organize the content with appropriate headings
- Improve readability and structure
- Keep all important information
- Make the tone professional but accessible
`;
const { text } = await writerAgent.generate([
{ role: "user", content: prompt }
]);
const message2 = "✅ Step 2: Rewriting completed.";
console.log(message2);
return {
rewrittenContent: text
};
}
});
// Create the complete workflow
export const webResearchWorkflow = createWorkflow({
id: "web-research-workflow",
description: "Web research and content rewriting workflow",
inputSchema: z.object({
query: z.string().describe("The research topic")
}),
outputSchema: z.object({
rewrittenContent: z.string().describe("The final rewritten and improved content")
})
})
.then(webSearchStep)
.then(rewriteStep)
.commit();

Here is my new master agent with the workflow and additional instructions in the prompt :

export const chefAgent = new Agent({
name: 'Chef Assistant',
instructions:
`You are a helpful global assistant that can help with various tasks and questions. ` +
`Today is ${today}. ` +
`Always reply in plain text without using Markdown formatting. ` +
`Do not use asterisks or any other symbols for bold or italic text. ` +
`You have access to different workflows that can help you provide better answers: ` +
`- rssWorkflow: **MANDATORY** - Use this IMMEDIATELY when users ask for news, actualités, current events, or tech updates. This provides a comprehensive tech digest from multiple sources. ` +
`- webResearchWorkflow: Use this when you need other current information, research, or web search ` +
`IMPORTANT RULES: ` +
`- When users ask for "actualités", "news", "dernières nouvelles", "infos du jour", or any news-related request: YOU MUST use rssWorkflow first ` +
`- Do not attempt to provide news from your knowledge - always use rssWorkflow for current news ` +
`- The rssWorkflow provides real-time tech news aggregated from Blog du Modérateur, Le Monde Informatique, and Forbes Innovation ` +
`- Keep conversations focused and concise to avoid token overflow ` +
`You can assist with: ` +
`- General questions and information ` +
`- News and current events (use rssWorkflow MANDATORY) ` +
`- Research and web search (use webResearchWorkflow) ` +
`- Technical help and explanations ` +
`- Creative tasks and brainstorming ` +
`Always try to be helpful, accurate, and use the available workflows when they can ` +
`improve your response quality.`,
model: google('gemini-2.5-flash-preview-04-17'),
memory,
workflows: { rssWorkflow, webResearchWorkflow },
});

And now let’s ask for more information about a news :

finalResponse

Conclusion

I hope this tutorial will make you want to use Mastra, it is a pleasant TypeScript framework to use with an active community and quick support from the founders.

The repo for this tutorial : https://github.com/Killian-Aidalinfo/discord-bot-actu

Also, if you want to participate in the community building of agents and workflows you can participate here : https://github.com/aidalinfo/tsai-registry

That’s all for me !

feel free to follow me on social networks :