Add files to your Postgres branches via Neon Object Storage, our S3-compatible object store

Build a Discord Bot with Neon Functions and Neon AI Gateway

Learn how to build a Discord bot with AI chat and image generation using Neon Functions and the Neon AI Gateway.

If you've spent any time on Discord, you've run into bots: moderation bots, music players, AI image generators like Midjourney, which started out as a Discord bot before becoming a standalone product. They all do the same basic thing under the hood: listen for a command and respond, whether that's a one-line reply or a fully generated image.

In this guide, you'll build your own minimal version of that: a Discord bot powered by Neon Functions and Neon AI Gateway. You'll implement:

  • A basic command to handle Discord's interaction flow
  • A /chat command that uses an LLM to answer questions
  • An /imagine command that generates images from text prompts

You will use the Neon AI Gateway to access LLMs and image generation tools, and Neon Functions to host your bot. The bot will respond to slash commands in Discord and respond with either text or images generated by the AI Gateway.

Prerequisites

Before you begin, ensure you have:

  1. Node.js: Version 24. Download from nodejs.org.
  2. Neon Account: Sign up for a free Neon account at console.neon.tech.
  3. Discord Account: Sign up for a free Discord account at discord.com.
  4. Neon CLI: Installed globally (npm i -g neon) and authenticated (neon auth). Checkout Neon CLI Quickstart for more details.
  1. Create the Discord Application

    You'll need to create a Discord application to get the credentials required to connect your Neon Function to Discord.

    1. Go to the Discord Developer Portal and click New Application. Name it "Neon Bot" and accept the terms.
    2. Under the General Information tab, copy the Application ID and Public Key. You will need these later.
    3. Navigate to the Bot tab in the sidebar. Click Reset Token and copy your Bot Token. Keep this token secret, as it allows anyone to control your bot.
    4. Keep the Developer Portal open. You will need to enter the bot's endpoint URL later, which will be generated when you deploy your Neon Function.
  2. Initialize your Neon Project

    Create a new directory for your bot and initialize a Neon Functions project.

    mkdir neon-discord-bot && cd neon-discord-bot

    Run the Neon CLI initialization command. This will prompt you to authenticate and link the directory to a Neon project:

    neon init

    When asked to choose a template, select No thanks - continue without scaffolding, since you'll be writing the code from scratch. During setup, install the Neon MCP server and extensions when prompted. The Neon agent skill will be automatically added to your project, enabling AI agents to assist with development tasks such as code generation, testing, and deployment.

    $ neon init
    
      ██╗  ██╗██████╗  ██████╗ ██╗  ██╗
      ███╗ ██║██╔═══╝ ██╔═══██╗███╗ ██║
      ████╗██║██████╗ ██║   ██║████╗██║
      ██╔████║██╔═══╝ ██║   ██║██╔████║
      ██║╚███║██████╗ ╚██████╔╝██║╚███║
      ╚═╝ ╚══╝╚═════╝  ╚═════╝ ╚═╝ ╚══╝
    
      Let's get your project set up with Neon. We'll install the MCP server, agent skills, and IDE extension, then connect your app to
      a database.
    
    
      Configuration checked
    
      Which Neon features would you like to enable for this project?
      Database
    
      Neon editor extension already installed
    
      Configure VS Code for Neon:
     Install with defaults (MCP server (global), agent skills (project))
     Customize installation
     Configure a different editor
    
      Agent skills installed

    Next, install the required dependencies:

    npm install hono discord-interactions @neon/ai-sdk-provider @neon/functions
    npm install --save-dev esbuild @types/node typescript
    • hono: A lightweight TypeScript-first web framework for building REST APIs.
    • discord-interactions: Discord's official library for implementing slash commands and verifying webhook signatures.
    • @neon/ai-sdk-provider: Neon's AI SDK Provider, which allows you to access LLMs and image generation tools.
    • @neon/functions: Neon's Functions SDK, which provides utilities for building serverless functions.
  3. Link your local project to a Neon project using the Neon CLI:

    neon link

    Follow the prompts to select your organization and create a new project:

    note

    Ensure you select the AWS US East 2 (Ohio) region when creating your Neon project, as Neon Functions are currently only available in this region during Beta. After linking, choose “yes” when prompted to manage the setup as code to automatically generate a neon.ts file for your project.

    $ neon link
    
     Which organization would you like to link? YOUR_ORG_NAME
     Which project would you like to link? Create new project…
     Name for the new project: neon-functions-discord
     Which region should the new project run in? AWS US East 2 (Ohio) (aws-us-east-2)
    Created project quiet-fog-09491284 ("neon-functions-discord") in aws-us-east-2.
    Linked /home/neon-discord-bot/.neon:
      orgId:     org-round-waterfall-61562384
      projectId: quiet-fog-09491284
      branch:    main
    
    INFO: Pulled 3 Neon variables into /home/neon-discord-bot/.env.local: NEON_BRANCH, DATABASE_URL, DATABASE_URL_UNPOOLED
     Manage this project's Neon setup as code? Adds a neon.ts you can edit and apply with `neon config apply`. … yes
    INFO: Created neon.ts with a starter policy.
    INFO: Installing @neon/config, @neon/env with npm…
    
    added 15 packages, and audited 42 packages in 3s
    
    7 packages are looking for funding
      `npm run fund` for details
    
    found 0 vulnerabilities
    INFO: Next: edit neon.ts, then run `neon config plan` to preview and `neon config apply`.
    INFO: Pulled 3 Neon variables into /home/neon-discord-bot/.env.local: NEON_BRANCH, DATABASE_URL, DATABASE_URL_UNPOOLED
  4. Configure environment variables

    Add the following environment variables to your .env.local file located at the root of your project. This file should already contain Neon variables such as NEON_BRANCH, DATABASE_URL and other Neon-specific variables. Append the Discord-specific variables listed below to the end of the file:

    DISCORD_APP_ID=your_application_id_here
    DISCORD_PUBLIC_KEY=your_public_key_here
    DISCORD_BOT_TOKEN=your_bot_token_here
  5. Build a basic Discord bot

    Create an index.ts file in the root of your project. This file will contain the code for your Discord bot, which will handle incoming slash commands and respond accordingly.

    Discord requires HTTP bots to cryptographically verify all incoming requests. If the signature is invalid, the bot must reject the request. The discord-interactions library provides a verifyKey function to handle this verification.

    import { Hono } from 'hono';
    import { verifyKey, InteractionType, InteractionResponseType } from 'discord-interactions';
    
    const app = new Hono();
    
    app.post('/', async (c) => {
        const signature = c.req.header('X-Signature-Ed25519');
        const timestamp = c.req.header('X-Signature-Timestamp');
        const rawBody = await c.req.text();
    
        if (!signature || !timestamp) return c.text('Missing headers', 401);
    
        const isValid = await verifyKey(rawBody, signature, timestamp, process.env.DISCORD_PUBLIC_KEY!);
        if (!isValid) return c.text('Invalid request signature', 401);
    
        const interaction = JSON.parse(rawBody) as {
            type: number
            data?: {
                name: string
                options?: { name: string; value: string }[]
            }
            token?: string
            application_id?: string
        }
    
        if (interaction.type === InteractionType.PING) return c.json({ type: InteractionResponseType.PONG });
    
        if (interaction.type === InteractionType.APPLICATION_COMMAND) {
            const commandName = interaction.data?.name;
            if (!commandName) return c.text('Missing command name', 400);
    
            if (commandName === 'reverse') {
                const text = interaction.data?.options?.[0]?.value || '';
                return c.json({
                    type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
                    data: { content: `🔄 **Reversed:** ${text.split('').reverse().join('')}` },
                });
            }
        }
    
        return c.text('Unknown interaction', 400);
    });
    
    export default app;

    The above code does the following:

    • Signature verification: When you register an interactions endpoint with Discord, anyone who knows the URL can send fake requests to it. To prevent this, Discord signs every request with your application's public key. The code extracts both the X-Signature-Ed25519 header and the X-Signature-Timestamp header, reads the raw body (JSON parsing would break the signature check), and passes everything to verifyKey. If verification fails, the request is rejected with a 401. See Discord's security documentation for details on how this handshake works.
    • PING/PONG handshake: When you first save your endpoint URL in the Discord Developer Portal, Discord sends a PING interaction to verify the endpoint is alive. Your bot must respond with a PONG (InteractionResponseType.PONG). If this exchange fails, Discord won't let you save the URL.
    • Slash command handling: When a user invokes /reverse, Discord sends an APPLICATION_COMMAND interaction. The bot reads the data.options array to extract the user's text input, reverses it, and returns a CHANNEL_MESSAGE_WITH_SOURCE response, which tells Discord to post the reply immediately in the channel.
    • Fallback: Any interaction type the bot doesn't recognize returns a 400 error. This is a safety net so unhandled interactions don't silently fail.
  6. Update neon.ts

    The neon link command created a neon.ts file in your project root. Update it to add the Discord bot function and configure the environment variables:

    import { defineConfig } from "@neon/config/v1";
    
    export default defineConfig({
      auth: false,
      branch: (branch) => {
        if (branch.isDefault) { return {}; }
        if (!branch.exists) { return { ttl: "7d" }; }
        return {};
      },
      preview: {
        functions: {
          bot: {
            name: "Discord Bot",
            source: "./index.ts",
            env: {
              DISCORD_APP_ID: process.env.DISCORD_APP_ID!,
              DISCORD_PUBLIC_KEY: process.env.DISCORD_PUBLIC_KEY!,
              DISCORD_BOT_TOKEN: process.env.DISCORD_BOT_TOKEN!,
            },
          },
        },
        aiGateway: true,
      },
    });

    Apply the configuration to activate the AI Gateway for your project:

    neon config apply --env .env.local
  7. Test locally before deploying

    You can run your Neon Function locally using neon dev before deploying it to production:

    neon dev

    This starts a local server that lets you test and debug your bot without deploying. To test the full flow end-to-end with Discord, you'll need a public HTTPS URL since Discord requires it for the interactions endpoint. Use ngrok to expose your local server:

    ngrok http 8787

    Copy the https://*.ngrok-free.app URL from ngrok's output and paste it into the Interactions Endpoint URL field in the Discord Developer Portal. This lets you iterate on your bot code locally while testing against the live Discord API.

  8. Deploy your bot

    With the initial code written, deploy your bot to Neon Functions:

    neon deploy --env .env.local

    The CLI will output something like this:

    neon deploy --env .env.local
    INFO: Applying to branch main (br-damp-voice-ajjys6qp)
    Applied changes
    ┌────────┬─────────┬──────────────┐
     Action Kind Identifier
    ├────────┼─────────┼──────────────┤
     update service function:bot
    └────────┴─────────┴──────────────┘
    
    Function URLs
     bot: https://br-damp-voice-xxx-bot.compute.c-3.us-east-2.aws.neon.tech
    
    Utilized services: Postgres, Functions

    Your bot is now live. Copy the function URL from the output (the https://...neon.tech/ line). If you need to retrieve it later, run neon functions get bot.

  9. Connect your bot to Discord

    Now that your bot is deployed, connect it to Discord:

    1. Go back to the Discord Developer Portal > General Information.
    2. Paste your Neon function URL into the Interactions Endpoint URL field and hit Save Changes. Discord Developer Portal Interactions Endpoint URL
    3. Discord will immediately send a PING request to your URL. If everything is configured correctly, it will show a green success message.
  10. Register your slash commands

    Discord doesn't know what commands your bot supports until you register them. Unlike some platforms where commands are auto-discovered, Discord requires you to explicitly register each command via the Application Commands REST API. This is a one-time step (per command). Once registered, the commands are available globally.

    Create a temporary script called register.js in your project root:

    const APP_ID = process.env.DISCORD_APP_ID;
    const BOT_TOKEN = process.env.DISCORD_BOT_TOKEN;
    
    const commands = [
      {
        name: 'reverse',
        description: 'Reverses your text',
        type: 1,
        options: [{ name: 'text', description: 'Text to reverse', type: 3, required: true }]
      },
      {
        name: 'chat',
        description: 'Ask the AI a question',
        type: 1,
        options: [{ name: 'prompt', description: 'Your prompt', type: 3, required: true }]
      },
      {
        name: 'imagine',
        description: 'Generate an image',
        type: 1,
        options: [{ name: 'prompt', description: 'Image description', type: 3, required: true }]
      }
    ];
    
    for (const command of commands) {
      fetch(`https://discord.com/api/v10/applications/${APP_ID}/commands`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bot ${BOT_TOKEN}`
        },
        body: JSON.stringify(command)
      })
        .then(res => res.json())
        .then(data => console.log(`Registered command: ${data.name}`))
        .catch(err => console.error(`Error registering command ${command.name}:`, err));
    }

    Here's what the script does:

    • Endpoint: Each command is POSTed to https://discord.com/api/v10/applications/{APP_ID}/commands. This is the Bulk Overwrite Global Application Commands endpoint. Commands registered this way are available in every server where your bot is installed.
    • Authentication: The request uses the bot token in the Authorization header. This is how Discord knows the request is coming from your bot.
    • Command structure: Each command object has a name (what users type after /), a description (shown in Discord's autocomplete), type: 1 (a CHAT_INPUT slash command), and options (the parameters users fill in). type: 3 means the option accepts a STRING value.

    Run it using Node (ensure your .env variables are loaded):

    export $(cat .env.local | xargs)
    node register.js
  11. Invite the bot to your server

    Generate an invite link to add the bot to your Discord server. This uses Discord's OAuth2 flow to authorize your bot for a specific server:

    1. In the Discord Developer Portal, go to OAuth2.
    2. Under OAuth2 URL Generator, check bot and applications.commands. The bot scope lets the bot join your server, and applications.commands lets it register slash commands.
    3. Copy the generated URL at the bottom, paste it into your browser, and invite the bot to your server.
    4. Alternatively, you can use the following URL template, replacing YOUR_APP_ID with your Discord Application ID: https://discord.com/oauth2/authorize?client_id=YOUR_APP_ID&scope=bot%20applications.commands&permissions=2147483648 Add discord bot to server You will be prompted to select a server where you have permission to add bots. After authorizing, your bot will appear in the server's member list.
  12. Test your bot

    Go to your Discord server and try the /reverse command:

    /reverse Hello World

    The bot should immediately respond with 🔄 **Reversed:** dlroW olleH. Your bot is now live and working. The /chat and /imagine commands are registered but won't work yet. You'll add AI support for those next.

  13. Add AI chat and image generation

    Now that your bot is live, you can add AI capabilities using the Neon AI Gateway. You'll implement two new commands:

    • /chat: Takes a user prompt and generates a text response using an LLM.
    • /imagine: Takes a user prompt and generates an image using the AI Gateway

    Similar to the /reverse command, your bot will take a user input (prompt) and return a response. Instead of reversing text, you’ll use the Neon AI Gateway to generate text or images.

    The 3-second timeout problem

    Discord requires your bot to respond to every interaction within 3 seconds. If you don’t, Discord treats the request as failed and shows the user an error. LLM inference and image generation may take longer than 3 seconds, so you can’t respond directly in the interaction handler.

    The solution is a deferred response. When your handler receives a /chat or /imagine command, it immediately tells Discord “I’m working on it” by returning DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE. Discord then shows the user a “Bot is thinking...” indicator. Meanwhile, your bot processes the request in the background and later edits the original response using the webhook URL Discord provided with the interaction.

    The key difference from /reverse:

    • /reverse returns the response immediately in the interaction handler (it's fast enough)
    • /chat and /imagine defer the response, then update it asynchronously once the AI finishes

    Each interaction includes a token (a short-lived webhook credential) and an application_id. Together, these let your bot construct the webhook URL https://discord.com/api/v10/webhooks/{application_id}/{token}/messages/@original. Sending a PATCH request to this URL updates the "Bot is thinking..." placeholder with the actual content. The @original reference means you're editing the first message your bot sent in response to the interaction.

    Update index.ts to include the Neon AI SDK and handle the /chat and /imagine commands:

    import { Hono } from 'hono';
    import { verifyKey, InteractionType, InteractionResponseType } from 'discord-interactions';
    import { neon } from '@neon/ai-sdk-provider';
    import { generateText } from 'ai';
    
    const app = new Hono();
    
    const sendChatResponse = async (prompt: string, token: string, application_id: string) => {
      try {
        const { text } = await generateText({
          model: neon('gpt-5-mini'),
          prompt,
        });
    
        await fetch(`https://discord.com/api/v10/webhooks/${application_id}/${token}/messages/@original`, {
          method: 'PATCH',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ content: text }),
        });
      } catch (err) {
        console.error('sendChatResponse failed:', err);
        await fetch(`https://discord.com/api/v10/webhooks/${application_id}/${token}/messages/@original`, {
          method: 'PATCH',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ content: 'Sorry, something went wrong generating a response.' }),
        }).catch(() => {});
      }
    };
    
    const sendImagineResponse = async (prompt: string, token: string, application_id: string) => {
      try {
        const { toolResults } = await generateText({
          model: neon('gpt-5-mini'),
          prompt,
          tools: {
            image_generation: neon.tools.imageGeneration({
              outputFormat: 'jpeg',
              size: '1024x1024',
              quality: 'low',
              outputCompression: 30,
            }),
          },
        });
    
        for (const tr of toolResults) {
          if (tr.toolName === 'image_generation') {
            const output = tr.output as { result: string } | undefined;
            const base64 = output?.result;
            if (base64) {
              const buffer = Buffer.from(base64, 'base64');
              const blob = new Blob([buffer], { type: 'image/jpeg' });
              const formData = new FormData();
              formData.append('files[0]', blob, 'image.jpg');
              formData.append('payload_json', JSON.stringify({ content: `🎨 **Generated:** *${prompt}*` }));
    
              await fetch(`https://discord.com/api/v10/webhooks/${application_id}/${token}/messages/@original`, {
                method: 'PATCH',
                body: formData,
              });
            }
          }
        }
      } catch (err) {
        console.error('sendImagineResponse failed:', err);
        await fetch(`https://discord.com/api/v10/webhooks/${application_id}/${token}/messages/@original`, {
          method: 'PATCH',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ content: 'Sorry, something went wrong generating the image.' }),
        }).catch(() => {});
      }
    };
    
    app.post('/', async (c) => {
        const signature = c.req.header('X-Signature-Ed25519');
        const timestamp = c.req.header('X-Signature-Timestamp');
        const rawBody = await c.req.text();
    
        if (!signature || !timestamp) return c.text('Missing headers', 401);
    
        const isValid = await verifyKey(rawBody, signature, timestamp, process.env.DISCORD_PUBLIC_KEY!);
        if (!isValid) return c.text('Invalid request signature', 401);
    
        const interaction = JSON.parse(rawBody) as {
            type: number
            data?: {
                name: string
                options?: { name: string; value: string }[]
            }
            token?: string
            application_id?: string
        }
    
        if (interaction.type === InteractionType.PING) return c.json({ type: InteractionResponseType.PONG });
    
        if (interaction.type === InteractionType.APPLICATION_COMMAND) {
            const commandName = interaction.data?.name;
            const { token, application_id } = interaction;
            if (!commandName || !token || !application_id) return c.text('Missing interaction data', 400);
    
            if (commandName === 'reverse') {
                const text = interaction.data?.options?.[0]?.value || '';
                return c.json({
                    type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
                    data: { content: `🔄 **Reversed:** ${text.split('').reverse().join('')}` },
                });
            }
    
            if (commandName === 'chat') {
                const prompt = interaction.data?.options?.[0]?.value || '';
                sendChatResponse(prompt, token, application_id).catch(console.error);
                return c.json({ type: InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE });
            }
    
            if (commandName === 'imagine') {
                const prompt = interaction.data?.options?.[0]?.value || '';
                sendImagineResponse(prompt, token, application_id).catch(console.error);
                return c.json({ type: InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE });
            }
        }
    
        return c.text('Unknown interaction', 400);
    });
    
    export default app;

    The above code does the following:

    • Imports: The neon provider from @neon/ai-sdk-provider and generateText from the Vercel AI SDK give you a unified interface for calling LLMs and image generation tools through the Neon AI Gateway. You don't need to wire up OpenAI, Claude, or any other provider directly. The Gateway handles routing.

    • sendChatResponse: This function runs after the deferred response has already been sent. It calls generateText with the user's prompt to get an LLM response, then sends a PATCH request to the webhook endpoint to replace the "Bot is thinking..." message with the generated text. The catch block handles failures gracefully. If the AI call throws, the bot still sends a user-friendly error message back to Discord instead of leaving the user stuck on "Bot is thinking..." forever.

    • sendImagineResponse: This function works similarly but uses the image_generation tool provided by the Neon AI SDK. The AI model decides when to invoke the tool based on the prompt. The tool returns the image as a base64-encoded string, which the function converts to a Blob and uploads to Discord as a file attachment using FormData. This is necessary because Discord's Edit Original Interaction Response endpoint accepts file uploads via multipart form data. You can't embed raw image data in a JSON payload.

    • Deferred responses in the handler: When the handler receives a /chat or /imagine command, it calls the corresponding async function (fire-and-forget with .catch(console.error)) and immediately returns DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE. This tells Discord to show the "thinking" indicator while the AI processes in the background. The actual response arrives later via the webhook PATCH.

    • The @original reference: Every interaction webhook URL ends with /messages/@original. This is a Discord convention that refers to the first message your bot sent in response to the interaction. In this case, it's the deferred "Bot is thinking..." message. Patching it replaces that placeholder with the final content.

  14. Deploy the updated bot

    Redeploy your bot to Neon Functions with the updated code:

    neon deploy --env .env.local

    The CLI will output the same deployment details as before. Your bot is now running the updated code with AI support.

  15. Test the AI commands

    Go to your Discord server and try the new commands:

    • /chat Who are you?: The bot will defer the message, generate a response using the AI Gateway, and reply with the generated text. Discord bot responding with generated text

    • /imagine An astronaut in a bustling cafe, sipping coffee: Similarly, the bot will defer the message, generate an image using the AI Gateway, and reply with the generated image.

      Discord bot responding with generated text and image Discord bot responding with generated text and image 2

    Your Discord bot is now fully functional with AI chat and image generation capabilities, all powered by Neon Functions and the Neon AI Gateway.

    You can now start charging users for generating images.

Extending this workflow

The bot you built is a starting point. Because Neon Functions can connect to Lakebase Postgres, you can turn this into a full SaaS product with user management, billing, and usage tracking. Here are some ideas:

  • User tracking: Store Discord user_id in a Postgres table to track who is using your bot, how often, and what commands they invoke. This gives you per-user analytics and a foundation for billing.
  • Paid access with Stripe: Gate premium commands like /chat and /imagine behind a payment wall. When a user invokes a paid command, look up their user_id in your database. If they haven't paid, reply with a Stripe Checkout link. Use Stripe webhooks to update your database when a payment succeeds.
  • Credits system: Instead of (or in addition to) subscriptions, implement a credits model. Give each user a monthly allowance of free AI calls, tracked in a credits_remaining column. Decrement on each /chat or /imagine invocation and prompt them to purchase more when they run out.
  • Conversation history: Store chat history per user in Postgres so /chat can maintain context across multiple messages, enabling multi-turn conversations.
  • Persistent image storage: Images generated by /imagine are ephemeral. They live only in the Discord message. Use Neon Storage to persist them. Save each generated image to a branch-scoped S3 bucket and return a presigned URL instead of uploading the raw image to Discord. This gives you a permanent gallery users can browse later and keeps your bot's responses fast since Discord message size limits won't be a concern.

Resources

Need help?

Join our Discord Server to ask questions or see what others are doing with Neon. For paid plan support options, see Support.

Was this page helpful?
Edit on GitHub