# tadpole

A Discord library for Gleam. Every frog starts as a tadpole.

why tadpole

Most Discord libraries are written for dynamic languages first. Tadpole is Gleam and OTP first, and the API is shaped by that choice:

Also included: endpoint bindings for GET /users/@me, sending a message, and replying (tadpole/rest/endpoints); model objects whose decoders report where a payload stopped matching (tadpole/model); and config validation that redacts the token in its own describe output (tadpole.describe_config).

A first bot and a sharded production bot should be the same framework at different sizes: growth is additive, and nothing you wrote at hello-world gets renamed when you need more. That contract lives in CONTRIBUTING.md, and reviewers hold changes to it.

where this actually is

The first vertical slice works. A bot can connect to Discord’s gateway, identify, heartbeat, resume after a disconnect, and receive typed events. REST calls run over gleam_httpc with rate-limit handling learned from response headers. The echo bot below is a complete program.

Not here yet, so do not assume it:

One honest caveat: everything above is tested against recorded payload shapes and canned HTTP responses. It has not run against real Discord from CI, and the package is not on Hex yet. The publish gate in CONTRIBUTING.md requires one live roundtrip (gateway connect through ready, plus one real REST call) first, and that check has not happened yet. Adjust trust accordingly.

a whole bot

Gleam 1.18+ and Erlang/OTP.

gleam add tadpole        # not on Hex yet; the publish gate comes first
$env:TADPOLE_TOKEN = "your-bot-token"   # PowerShell

This program lives locally in dev/echo_bot.gleam. That folder stays out of version control because it holds token-driven scripts, so the listing above is the copy to trust. The env helper at the bottom is the only Erlang in the program; tadpole ships it, though it is private and allowed to move.

import gleam/io
import gleam/list
import gleam/string
import tadpole
import tadpole/bot
import tadpole/error/render
import tadpole/gateway/events.{type Event, MessageCreate, Ready}
import tadpole/intent

pub fn main() {
  case env("TADPOLE_TOKEN") {
    Ok(token) if token != "" -> run(token)
    _ ->
      io.println(
        "echo_bot: no token found.\n"
        <> "  1. Create a bot and copy its token: "
        <> "https://discord.com/developers/applications → Bot → Reset Token\n"
        <> "  2. Set it: $env:TADPOLE_TOKEN = \"your-token\" then run "
        <> "gleam run -m echo_bot",
      )
  }
}

fn run(token: String) {
  let cfg =
    tadpole.new(token)
    |> tadpole.with_intents(
      intent.new()
      |> intent.enable(intent.guilds)
      |> intent.enable(intent.guild_messages)
      |> intent.enable(intent.message_content),
    )

  // Message Content is privileged: without the portal toggle Discord
  // hangs up with close code 4014 the moment we identify.
  case tadpole.privileged_intents_requested(cfg) {
    [] -> Nil
    privileged ->
      io.println(
        "echo_bot: this config requests privileged intents ("
        <> join_names(privileged)
        <> "). Enable them in the Discord Developer Portal → Bot → "
        <> "Privileged Gateway Intents, or the gateway will close the "
        <> "connection with code 4014 (disallowed intents).",
      )
  }

  case bot.run(cfg, handle_event) {
    Ok(_) -> Nil
    Error(e) -> io.println(render.render_error(e))
  }
}

fn handle_event(tadbot: bot.Bot, event: Event) {
  case event {
    Ready(user, _) -> io.println("logged in as " <> user.username)

    MessageCreate(message) ->
      case message.author.bot {
        // Echoing our own messages would loop forever; empty content is
        // an embed-only post, which a plain text reply cannot echo.
        True -> Nil
        False ->
          case message.content {
            "" -> Nil
            content -> {
              let result =
                bot.reply(tadbot, message.channel_id, message.id, content)
              case result {
                Ok(_) -> Nil
                Error(e) -> io.println(render.render_error(e))
              }
            }
          }
      }

    _ -> Nil
  }
}

fn join_names(bits: List(Int)) -> String {
  bits
  |> list.map(intent.intent_name)
  |> string.join(", ")
}

@external(erlang, "tadpole_ffi", "get_env")
fn env(name: String) -> Result(String, Nil)

To run it against this repo’s source, save the program as dev/echo_bot.gleam in your clone. The dev/ folder is gitignored, so it is yours to create, and gleam run -m echo_bot picks the module up from there:

gleam run -m echo_bot

Message Content is a privileged intent. Enable it in the Discord Developer Portal (Bot → Privileged Gateway Intents) or drop it from the config. Without the toggle, Discord hangs up with close code 4014 the moment the bot identifies. The example prints a reminder before that happens; it cannot check the portal for you.

running the tests

gleam test

All tests run against recorded fixtures and canned HTTP responses. No network in CI. The suite has three layers (unit, property, contract); TESTING.md is the map: what each layer is for and how to add to it.

The one exception is the env-gated live gate: with TADPOLE_TOKEN set, gleam test also runs CONTRIBUTING’s publish gate for real (REST authentication, gateway connect through READY, typed events, clean close). Without the variable it skips. See TESTING.md.

Smoke runs against real Discord happen by hand, never from CI, with a token in TADPOLE_TOKEN. The smoke scripts live in dev/ and are not part of the published tree; they are how the publish gate gets run. Both exit immediately without the token set:

gleam run -m echo_bot     # the echo bot: repeats non-bot messages
gleam run -m smoke_gw     # connects one shard, prints events for 60s, exits

Windows note: the Erlang installer doesn’t add itself to PATH. Add C:\Program Files\Erlang OTP\bin yourself.

contributing

Read CONTRIBUTING.md. Short version: zero compiler warnings, tests for everything public, and nothing gets described in this README before it has a test.

Search Document