# Build a Discord bot that monitors a Minecraft server
In this tutorial you will build a small discord.js bot that polls one Minecraft server, then creates or updates a single Discord embed with its current state. It only posts again when the state changes, so the channel does not fill with repeated status messages.

The bot uses `GET /service-instances/{id}/resources` and `GET /service-instances/{id}/external-access` from the [API reference](/api). Conventions shared by every call are in [Using the Zaroz API](/guides/api/overview).

## What the finished bot does

- Reads one service every 60 seconds.
- Shows the overall state and primary Minecraft service state.
- Includes the public address when the API returns one.
- Changes embed colour for running, transitional, stopped, and failed states.
- Finds its previous status message after a restart and edits it.
- Shows an API error in the same embed without exposing the token.

## 1. Create the Discord application

1. Open the [Discord Developer Portal](https://discord.com/developers/applications) and create an application.
2. Open **Bot**, create the bot user, and copy its token into a password manager.
3. Under **OAuth2 → URL Generator**, select the `bot` scope.
4. Grant **View Channels**, **Send Messages**, **Embed Links**, and **Read Message History**.
5. Use the generated URL to invite the bot to your server.
6. Enable Developer Mode in Discord, right-click the destination channel, and copy its channel ID.

This bot does not need Message Content or another privileged gateway intent.

## 2. Create the Zaroz API token

In **Settings → Security → API tokens**, create a token named `discord-minecraft-status`:

- Capability: `services.read`
- Scope: **One resource**
- Resource: your Minecraft server (the service instance id, as in its console URL)

That is the only Zaroz capability this tutorial needs. Copy the token when it is shown; it cannot be displayed again.

## 3. Create the project

```bash
mkdir zaroz-minecraft-status
cd zaroz-minecraft-status
npm init -y
npm install discord.js dotenv
```

Replace `package.json` with:

```json title="package.json"
{
  "name": "zaroz-minecraft-status",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "discord.js": "^14.26.5",
    "dotenv": "^17.4.2"
  }
}
```

Create `index.js`:

```js title="index.js"
import "dotenv/config";
import {
  Client,
  EmbedBuilder,
  Events,
  GatewayIntentBits,
} from "discord.js";

const requiredVariables = [
  "DISCORD_TOKEN",
  "DISCORD_CHANNEL_ID",
  "ZAROZ_API_TOKEN",
  "ZAROZ_SERVICE_ID",
];

for (const variable of requiredVariables) {
  if (!process.env[variable]) {
    throw new Error(`Missing environment variable: ${variable}`);
  }
}

const API_BASE = "https://kernel.zaroz.cloud/api/v2";
const pollInterval = Math.max(
  Number(process.env.POLL_INTERVAL_SECONDS ?? 60),
  15,
) * 1_000;
const marker = `zaroz-service:${process.env.ZAROZ_SERVICE_ID}`;

const colours = {
  running: 0x22c55e,
  provisioning: 0x3b82f6,
  pending: 0x3b82f6,
  stopping: 0xf59e0b,
  suspended: 0x64748b,
  crash_looping: 0xef4444,
  degraded: 0xf97316,
  failed: 0xef4444,
};

const client = new Client({ intents: [GatewayIntentBits.Guilds] });
let channel;
let statusMessage;
let lastFingerprint;
let updateInProgress = false;

async function zaroz(path) {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 10_000);

  try {
    const response = await fetch(`${API_BASE}${path}`, {
      headers: {
        Authorization: `Bearer ${process.env.ZAROZ_API_TOKEN}`,
        Accept: "application/json",
      },
      signal: controller.signal,
    });

    if (!response.ok) {
      throw new Error(`Zaroz API returned HTTP ${response.status}`);
    }

    return response.json();
  } finally {
    clearTimeout(timeout);
  }
}

async function getStatus() {
  const id = process.env.ZAROZ_SERVICE_ID;
  const resources = await zaroz(`/service-instances/${id}/resources`);
  const primary = resources.data.find((item) => item.is_declared_primary);

  // The public address is optional: leave it out if this token cannot read it.
  let address;
  try {
    const access = await zaroz(`/service-instances/${id}/external-access`);
    const port = access.allocations.find((item) => item.is_primary);
    if (port) address = `${port.external_ip}:${port.external_port}`;
  } catch {
    address = undefined;
  }

  return { state: primary?.status ?? "pending", address };
}

function buildStatusEmbed(status) {
  const fields = [
    { name: "Minecraft service", value: status.state, inline: true },
  ];

  if (status.address) {
    fields.push({
      name: "Address",
      value: `\`${status.address}\``,
      inline: false,
    });
  }

  return new EmbedBuilder()
    .setTitle("Minecraft server status")
    .setColor(colours[status.state] ?? 0x64748b)
    .addFields(fields)
    .setFooter({ text: marker })
    .setTimestamp();
}

function buildErrorEmbed(error) {
  return new EmbedBuilder()
    .setTitle("Minecraft server status unavailable")
    .setDescription(error.message.slice(0, 2_000))
    .setColor(0xef4444)
    .setFooter({ text: marker })
    .setTimestamp();
}

async function findPreviousMessage() {
  const messages = await channel.messages.fetch({ limit: 50 });

  return messages.find(
    (message) =>
      message.author.id === client.user.id &&
      message.embeds.some((embed) => embed.footer?.text === marker),
  );
}

async function publish(embed) {
  statusMessage ??= await findPreviousMessage();

  if (statusMessage) {
    statusMessage = await statusMessage.edit({ embeds: [embed] });
  } else {
    statusMessage = await channel.send({ embeds: [embed] });
  }
}

async function updateStatus() {
  if (updateInProgress) return;
  updateInProgress = true;

  try {
    const status = await getStatus();
    const fingerprint = JSON.stringify(status);

    if (fingerprint !== lastFingerprint) {
      await publish(buildStatusEmbed(status));
      lastFingerprint = fingerprint;
    }
  } catch (error) {
    const safeError =
      error instanceof Error ? error : new Error("Unknown status error");
    const fingerprint = `error:${safeError.message}`;

    if (fingerprint !== lastFingerprint) {
      await publish(buildErrorEmbed(safeError));
      lastFingerprint = fingerprint;
    }

    console.error("Status update failed:", safeError.message);
  } finally {
    updateInProgress = false;
  }
}

client.once(Events.ClientReady, async (readyClient) => {
  console.log(`Logged in as ${readyClient.user.tag}`);
  channel = await readyClient.channels.fetch(
    process.env.DISCORD_CHANNEL_ID,
  );

  if (!channel?.isTextBased() || !("messages" in channel)) {
    throw new Error("DISCORD_CHANNEL_ID is not a text channel");
  }

  await updateStatus();
  setInterval(updateStatus, pollInterval);
});

client.login(process.env.DISCORD_TOKEN);
```

## 4. Configure and run it

For a local test, create `.env`:

```dotenv title=".env"
DISCORD_TOKEN=your-discord-bot-token
DISCORD_CHANNEL_ID=000000000000000000
ZAROZ_API_TOKEN=your-zaroz-api-token
ZAROZ_SERVICE_ID=00000000-0000-0000-0000-000000000000
POLL_INTERVAL_SECONDS=60
```

Add `.env` to `.gitignore`, then start the bot:

```bash
printf '.env\n' >> .gitignore
npm start
```

For a Zaroz Discord Bot service, upload `package.json`, `package-lock.json`, and `index.js`, set the five variables in the service's environment configuration, and use `npm start` as the start command. Do not upload `.env`.

## 5. Verify least privilege

The bot should be able to read `GET /service-instances/{id}/resources`, but `POST /service-instances/{id}/restart` should return `403`. That is intentional: a public status integration should not be able to restart the server.

If you later add a Discord restart command, create a deliberate permission flow and add `services.power` for this service. Restrict the Discord command to trusted roles and record who invoked it.

## Troubleshooting

<details>
<summary>The embed says HTTP 401</summary>

Replace an expired or revoked token. Confirm you copied the complete token and did not add quotes to the dashboard environment value.

</details>

<details>
<summary>The embed says HTTP 403</summary>

Give the token `services.read` for this exact service. The token owner must also still be able to see the service.

</details>

<details>
<summary>The bot creates a new message after every restart</summary>

Grant **Read Message History** and keep the original status message among the channel's most recent 50 messages.

</details>

<details>
<summary>The address is missing</summary>

The API only includes an address on a provision that exposes one. The overall state monitor still works without it.

</details>

<details>
<summary>How often should I poll?</summary>

Sixty seconds is a good default. The example enforces a 15-second minimum. For faster event-driven integrations, check the [OpenAPI reference](https://kernel.zaroz.cloud/docs) for newer platform capabilities before increasing request volume.

</details>
