Skip to content
  1. Docs
  2. Integrations
  3. Slack

Slack astrology bot, slash commands and tarot

Ship /horoscope, /tarot, and /lifepath in a Slack workspace in about 15 minutes. Paste one app manifest, add one handler, reply with Block Kit.

Slack gives a bot three things to build on: slash commands, the Events API, and the official Bolt SDK for Node.js and Python. RoxyAPI supplies the answers: 258+ endpoints across 18+ domains on one key, and a Remote MCP server per domain when you want a model to choose the call itself.

Pick the path first

Two shapes, and the choice decides everything below.

You wantBuildRoxyAPI path
/horoscope aries returns the same card every timeA command botOne endpoint per command, called from your handler
A DM that answers "what does my chart say about my career?"An AI botRemote MCP, one URL, tools discovered at runtime

A command bot is cheaper, deterministic, and needs no model. An AI bot does not need you to wire endpoints at all: point the model at https://roxyapi.com/mcp/{domain} and it reads the tool catalog itself. Start at the command bot below, then add the MCP path when free text arrives.

Step 1: prove the call

Before Slack is involved at all, confirm your key works. Get one on the pricing page, then:

curl "https://roxyapi.com/api/v2/astrology/horoscope/aries/daily" \
  -H "X-API-Key: $ROXY_API_KEY"

A 200 returns clean JSON with sign, date, overview, love, career, luckyNumber, moonSign, moonPhase, and energyRating, plus column, the whole reading as one ready-to-post block of 120 to 180 words. Errors come back as { error, code }. Nothing is wrapped in a success envelope.

column is the fastest path to a good-looking Slack reply: one field, already written as prose, well inside the 3000 character cap on a Block Kit section. Use the six topic fields (overview, love, career, health, finance, advice) only when you want a block per life area. They read the same dated events as column, each written as a column of its own, so render one shape or the other, never both.

Step 2: create the Slack app from a manifest

Manifests beat clicking through panels: one paste sets the command, the scopes, and the request URL together. At api.slack.com/apps, choose Create New App, then From a manifest, pick the workspace, and paste this. Replace the domain with your own.

{
  "display_information": { "name": "Roxy" },
  "features": {
    "bot_user": { "display_name": "Roxy", "always_online": false },
    "slash_commands": [
      {
        "command": "/horoscope",
        "url": "https://your-app.example.com/slack/events",
        "description": "Get the daily horoscope",
        "usage_hint": "[zodiac sign]",
        "should_escape": false
      },
      {
        "command": "/tarot",
        "url": "https://your-app.example.com/slack/events",
        "description": "Draw the daily tarot card",
        "should_escape": false
      }
    ]
  },
  "oauth_config": { "scopes": { "bot": ["commands", "chat:write"] } },
  "settings": {
    "interactivity": { "is_enabled": true },
    "org_deploy_enabled": false,
    "socket_mode_enabled": false,
    "token_rotation_enabled": false
  }
}

Install to the workspace, then copy the Bot User OAuth Token (xoxb-) and the Signing Secret from Basic Information. Both go in your environment, never in a repo.

Slack POSTs every slash command to the url in the manifest. Bolt verifies the signing secret on every request before your handler runs, so you set signingSecret and write no crypto. The endpoint must be HTTPS; use ngrok locally.

Step 3: the /horoscope handler

Acknowledge inside 3 seconds, then call RoxyAPI, then reply. The handler body is identical under either transport.

// npm install @slack/bolt @roxyapi/sdk
import { App } from '@slack/bolt';
import { createRoxy } from '@roxyapi/sdk';

const roxy = createRoxy(process.env.ROXY_API_KEY!);
const app = new App({
  token: process.env.SLACK_BOT_TOKEN,
  signingSecret: process.env.SLACK_SIGNING_SECRET,
});

app.command('/horoscope', async ({ command, ack, respond }) => {
  await ack();
  const sign = command.text.trim().toLowerCase();
  if (!sign) {
    return respond({ text: 'Usage: `/horoscope aries`' });
  }

  const { data, error } = await roxy.astrology.getDailyHoroscope({
    path: { sign },
  });
  if (error) {
    return respond({ text: `Could not read ${sign}: ${error.code}` });
  }

  await respond({
    response_type: 'in_channel',
    blocks: [
      {
        type: 'header',
        text: { type: 'plain_text', text: `${data.sign} for ${data.date}` },
      },
      {
        type: 'section',
        text: { type: 'mrkdwn', text: data.column },
      },
      {
        type: 'context',
        elements: [
          {
            type: 'mrkdwn',
            text: `Lucky number *${data.luckyNumber}*, Moon in ${data.moonSign} (${data.moonPhase}), energy ${data.energyRating}/10`,
          },
        ],
      },
    ],
  });
});

(async () => {
  await app.start(3000);
})();

Type /horoscope aries in any channel the app is installed in. Drop response_type and the reply is ephemeral instead, visible only to whoever ran the command.

Add /tarot with the card image

POST /tarot/daily returns card.imageUrl, a public CDN URL, so a Block Kit image block can point straight at it with nothing to host. Passing seed makes the draw reproducible: the same seed on the same date always returns the same card, so a member who runs /tarot ten times before midnight sees one card.

app.command('/tarot', async ({ command, ack, respond }) => {
  await ack();
  const { data } = await roxy.tarot.getDailyCard({
    body: { seed: command.user_id },
  });
  await respond({
    response_type: 'in_channel',
    blocks: [
      {
        type: 'image',
        image_url: data.card.imageUrl,
        alt_text: data.card.name,
        title: {
          type: 'plain_text',
          text: `${data.card.name}${data.card.reversed ? ' (reversed)' : ''}`,
        },
      },
      { type: 'section', text: { type: 'mrkdwn', text: data.dailyMessage } },
    ],
  });
});

Add the files:write scope only if you would rather upload the image than link it.

Step 4: more commands

Every command is the same three moves: pick the endpoint, build the parameters, format the blocks. Browse everything in the API reference.

The endpoints that fit a slash reply best:

Any chart, panchang, dasha, synastry or compatibility call needs latitude, longitude, and timezone. Resolve them from the city with GET /location/search (roxy.location.searchCities in TypeScript, roxy.location.search_cities in Python) and pass the IANA timezone straight through. The server resolves DST for the chart date, so a January birth in New York gets EST and a July birth gets EDT.

Add an AI bot on Remote MCP

Hardcoded commands stop at the questions you predicted. For free text in DMs, connect the model to a Remote MCP server and let it choose.

The servers run at https://roxyapi.com/mcp/{domain} over Streamable HTTP, POST only. No stdio, no Docker, no local process. Authenticate with the X-API-Key header or Authorization: Bearer. The model calls tools/list, sees the typed catalog with field descriptions and enums, picks a tool, and calls it. You wire no endpoints. Inside a Bolt app.message handler, forward the text to your agent and post the answer back with say().

Tool discovery is free. Each tools/call counts as one request, the same as the equivalent REST call. Full setup, per-client configs, and the compact argument that cuts agent token cost are on the Remote MCP page. If you would rather have a coding agent build the whole bot, the prompts page hands it the truth sources first.

Frequently asked questions

Does the reply go to the channel or just to me?

Just to you, unless you say otherwise. A response_url reply is ephemeral by default, so only the member who ran the command sees it. Set response_type: 'in_channel' in the payload, as the handlers above do, to post it for everyone. Declare it either way rather than relying on the default.

Can I call ack() after the RoxyAPI call?

No. Slack wants the acknowledgement within 3000 milliseconds or the member sees an operation_timeout error. Call ack() first, then RoxyAPI, then respond(), which posts through the response_url Slack sent with the payload. Bolt for Python also has lazy listeners for work that runs long.

How long is response_url good for?

Up to 5 responses within 30 minutes of the payload. After that, post with client.chat.postMessage instead.

How do I DM a member instead?

client.chat.postMessage({ channel: command.user_id, ... }). Slack accepts a user ID as a DM channel. Add the im:write scope.

Can the bot reply in another language?

Yes. Most domains take a lang query parameter: en, tr, de, es, hi, pt, fr, ru, zh-Hans, zh-Hant. Pass query: { lang: 'hi' } in TypeScript or lang='hi' in Python. Machine values such as sign names stay canonical; the prose translates in place.

What scopes do I need beyond commands?

chat:write to reply. im:write for DMs. files:write only if you upload images rather than linking a URL in an image block.

Gotchas

  • Backend-only key. The RoxyAPI key, the bot token, and the signing secret all stay in your handler. Never echo one into a channel and never commit one.
  • 3 second acknowledgement. Always await ack() before the API call, never after.
  • Posting is rate limited per channel. Slack expects no more than one message per second per channel and allows short bursts above it. Bolt does not throttle for you, so a bulk send needs explicit pacing.
  • Block Kit has hard caps. A section text maxes at 3000 characters and an image title at 2000. A daily column fits comfortably; a full natal chart interpretation does not, so truncate or split across sections.
  • Slack mrkdwn is not Markdown. Bold is *text* with single asterisks, italic is _text_. There are no headings, so use a header block.
  • Prefer the IANA timezone. "Europe/London" beats 5.5, because the server resolves DST for the chart date.
  • Tool discovery is free, tool calls are not. Debugging an agent against tools/list costs nothing; every tools/call is one request.

What to build next