Skip to content
Guides/Gleam + Wisp

How to Block AI Bots on Gleam + Wisp: Complete 2026 Guide

Gleam is a statically typed language on the BEAM VM — Erlang's runtime. Wisp is its HTTP framework, using function composition for middleware via Gleam's use keyword. Middleware signature: fn(Request, fn(Request) -> Response) -> Response. To block: return wisp.response(403) without calling next. Header access returns Result(String, Nil) — exhaustive by type.

The use keyword — Gleam's middleware sugar

use req <- bot_blocker(req) is syntactic sugar for bot_blocker(req, fn(req) { ... }). The compiler rewrites it. This makes nested middleware readable without callback pyramids. Multiple layers compose left-to-right: use req <- layer_one(req) then use req <- layer_two(req) layer_one fires first.

Protection layers

1
robots.txtServed via path check before middleware — wisp.path_segments(req) == ["robots.txt"] bypasses bot-blocker
2
noai meta tagIn HTML string body — <meta name="robots" content="noai, noimageai"> in <head>
3
X-Robots-Tag headerwisp.set_header(response, "X-Robots-Tag", "noai, noimageai") — piped onto every response from the middleware
4
Hard 403 — global (via use)wisp.response(403) returned directly from bot_blocker — next never called
5
Hard 403 — scoped to /api/*Apply use req <- middleware.bot_blocker(req) only in the ["api", ..rest] branch

Step 1 — Bot detection module (src/ai_bots.gleam)

A Gleam const list — allocated once at module load. list.any short-circuits on first match. string.contains for substring check. The caller is responsible for lowercasing ua before passing it — keeps this module pure.

// src/ai_bots.gleam — bot detection module

import gleam/list
import gleam/string

const patterns = [
  // OpenAI
  "gptbot", "chatgpt-user", "oai-searchbot",
  // Anthropic
  "claudebot", "claude-web",
  // Common Crawl
  "ccbot",
  // Bytedance
  "bytespider",
  // Meta
  "meta-externalagent",
  // Perplexity
  "perplexitybot",
  // Google AI
  "google-extended", "googleother",
  // Cohere
  "cohere-ai",
  // Amazon
  "amazonbot",
  // Diffbot
  "diffbot",
  // AI2
  "ai2bot",
  // DeepSeek
  "deepseekbot",
  // Mistral
  "mistralai-user",
  // xAI
  "xai-bot",
  // You.com
  "youbot",
  // DuckDuckGo AI
  "duckassistbot",
]

/// Check if a User-Agent string belongs to a known AI bot.
/// ua should already be lowercased before calling.
pub fn is_ai_bot(ua: String) -> Bool {
  list.any(patterns, fn(pattern) { string.contains(ua, pattern) })
}

Step 2 — Middleware function (src/middleware.gleam)

The function signature is the Wisp middleware contract: fn(Request, fn(Request) -> Response) -> Response. Return directly for 403; call next(req) to continue. request.get_header returns Result(String, Nil) — the type system enforces the absent-header case.

// src/middleware.gleam — Wisp middleware via use keyword

import gleam/http/request
import gleam/result
import gleam/string
import wisp

import ai_bots

/// bot_blocker is a Wisp middleware function.
/// Signature: fn(Request, fn(Request) -> Response) -> Response
///
/// Call with: use req <- bot_blocker(req)
/// Gleam desugars use to: bot_blocker(req, fn(req) { ... })
pub fn bot_blocker(
  req: wisp.Request,
  next: fn(wisp.Request) -> wisp.Response,
) -> wisp.Response {
  // request.get_header returns Result(String, Nil)
  // result.unwrap provides "" as default if the header is absent.
  // HTTP headers are normalised to lowercase by gleam_http — use "user-agent".
  let ua =
    req
    |> request.get_header("user-agent")
    |> result.unwrap("")
    |> string.lowercase

  case ai_bots.is_ai_bot(ua) {
    True ->
      // Short-circuit: return 403 immediately.
      // The next callback is never called — inner handler never runs.
      wisp.response(403)
      |> wisp.set_header("X-Robots-Tag", "noai, noimageai")
      |> wisp.string_body("Forbidden")

    False ->
      // Pass to the next handler, then add X-Robots-Tag to the response.
      next(req)
      |> wisp.set_header("X-Robots-Tag", "noai, noimageai")
  }
}

Step 3 — Request router with use middleware

wisp.path_segments(req) returns the path as a List(String). Pattern match on it to route. Serve robots.txt at the top before applying the bot-blocker. All other paths go through use req <- middleware.bot_blocker(req).

// src/app.gleam — request router with bot-blocker middleware

import gleam/http
import gleam/http/request
import wisp

import middleware

const robots_txt = "User-agent: *
Allow: /

User-agent: GPTBot
Disallow: /

User-agent: ClaudeBot
Disallow: /

User-agent: CCBot
Disallow: /

User-agent: Bytespider
Disallow: /

User-agent: Google-Extended
Disallow: /

User-agent: PerplexityBot
Disallow: /

User-agent: Meta-ExternalAgent
Disallow: /

User-agent: YouBot
Disallow: /

User-agent: AmazonBot
Disallow: /

User-agent: Diffbot
Disallow: /
"

pub fn handle_request(req: wisp.Request) -> wisp.Response {
  // Serve robots.txt BEFORE the bot-blocker middleware.
  // All crawlers — including AI bots — must be able to read it.
  case wisp.path_segments(req) {
    ["robots.txt"] ->
      wisp.ok()
      |> wisp.set_header("Content-Type", "text/plain; charset=utf-8")
      |> wisp.string_body(robots_txt)

    _ -> {
      // Apply bot-blocker to all other routes.
      // use desugars to: middleware.bot_blocker(req, fn(req) { ... })
      use req <- middleware.bot_blocker(req)
      router(req)
    }
  }
}

fn router(req: wisp.Request) -> wisp.Response {
  case wisp.path_segments(req) {
    [] -> handle_home(req)
    ["health"] -> wisp.ok() |> wisp.string_body("ok")
    ["api", "data"] -> handle_api_data(req)
    _ -> wisp.not_found()
  }
}

fn handle_home(_req: wisp.Request) -> wisp.Response {
  let html = "<!DOCTYPE html>
<html>
<head>
  <meta name=\"robots\" content=\"noai, noimageai\">
  <title>My Site</title>
</head>
<body><h1>Welcome</h1></body>
</html>"
  wisp.ok()
  |> wisp.set_header("Content-Type", "text/html; charset=utf-8")
  |> wisp.string_body(html)
}

fn handle_api_data(req: wisp.Request) -> wisp.Response {
  use <- wisp.require_method(req, http.Get)
  wisp.ok()
  |> wisp.set_header("Content-Type", "application/json")
  |> wisp.string_body("{\"data\":\"protected\"}")
}

Step 4 — Start the server (src/main.gleam)

Wisp requires a secret_key_base for signed cookies and session encryption. wisp_mist.handler wraps your handler function for Mist (the underlying HTTP server).process.sleep_forever() keeps the BEAM process alive after startup.

// src/main.gleam — start Mist server with Wisp handler

import gleam/erlang/process
import mist
import wisp
import wisp_mist

import app

pub fn main() {
  wisp.configure_logger()
  let secret_key_base = wisp.random_string(64)

  let assert Ok(_) =
    wisp_mist.handler(app.handle_request, secret_key_base)
    |> mist.new
    |> mist.port(8080)
    |> mist.start_http

  process.sleep_forever()
}

// gleam.toml — dependencies
// [dependencies]
// gleam_stdlib = ">= 0.34.0, < 2.0.0"
// gleam_http = ">= 3.0.0, < 4.0.0"
// wisp = ">= 1.4.0, < 2.0.0"
// wisp_mist = ">= 1.4.0, < 2.0.0"
// mist = ">= 4.0.0, < 5.0.0"
// gleam_erlang = ">= 0.25.0, < 1.0.0"

Step 5 — Scoped bot-blocking and middleware stacking

Apply use only inside specific branches of your pattern match to scope the middleware. Multiple use lines in sequence compose naturally — each layer wraps the next. The order in code is the execution order.

// Scoped bot-blocking — only protect /api/* routes

pub fn handle_request(req: wisp.Request) -> wisp.Response {
  case wisp.path_segments(req) {
    // Public routes — no bot-blocker
    ["robots.txt"] -> serve_robots_txt()
    ["health"] -> wisp.ok() |> wisp.string_body("ok")
    [] -> handle_home(req)

    // Protected API routes — bot-blocker applied only here
    ["api", ..rest] -> {
      use req <- middleware.bot_blocker(req)
      handle_api(req, rest)
    }

    _ -> wisp.not_found()
  }
}

// Multiple middleware layers compose naturally with use
fn handle_api(req: wisp.Request, _path: List(String)) -> wisp.Response {
  // use desugars left-to-right — bot_blocker fires first, then auth_check
  use req <- middleware.rate_limiter(req)
  // inner handler
  wisp.ok()
  |> wisp.set_header("Content-Type", "application/json")
  |> wisp.string_body("{\"ok\":true}")
}

Gleam Wisp vs Elixir Plug vs Erlang Cowboy vs Phoenix

FeatureGleam / WispElixir / PlugErlang / CowboyElixir / Phoenix
Middleware modelFunction composition via use keyword — fn(Request, fn(Request)->Response)->Responseinit/1 + call/2 — Plug struct pipeline, plug macro in Plug.BuilderMiddleware via cowboy_middleware behaviour — execute/2 callbackplug macro in Phoenix.Endpoint and Phoenix.Router pipelines
Short-circuitReturn wisp.response(403) without calling next — next callback is never invokedsend_resp(conn, 403, "Forbidden") |> halt() — halt() required to stop pipelinereturn {stop, Req, State} from execute/2 — halts middleware chainconn |> send_resp(403, "Forbidden") |> halt() — same as Plug
UA header accessrequest.get_header(req, "user-agent") → Result(String, Nil) — always safeget_req_header(conn, "user-agent") → List(String) — List.first/2 for safetycowboy_req:header(<<"user-agent">>, Req, <<>>) — binary strings, default argSame as Plug — get_req_header/2 returns list
Static files (robots.txt)Path check before middleware: case wisp.path_segments(req) { ["robots.txt"] -> ... }Plug.Static before AiBotBlocker in pipeline — static files auto-haltcowboy_static handler for static paths, configured in routesplug Plug.Static in endpoint.ex before bot-blocker plug
Type safetyFull static types — Result, Option, exhaustive case — no runtime type errorsElixir dynamic typing with Dialyzer optional type specsErlang dynamic typing — atoms and binaries, Dialyzer optionalElixir dynamic typing, Dialyzer, Ecto changesets for data types
BEAM runtimeYes — compiles to Erlang, runs on OTP with full supervision treesYes — native Elixir/Erlang on BEAMYes — native Erlang on BEAM, the HTTP server under most BEAM frameworksYes — Elixir on BEAM, uses Cowboy/Bandit as HTTP server
use keywordGleam-specific sugar for CPS: use x <- f(y) → f(y, fn(x) { ... })No equivalent — Elixir uses |> pipe, with for nested patternsNo equivalent — Erlang uses function calls and pattern matchingNo equivalent — Phoenix uses Plug pipeline macros

Summary

  • use req <- bot_blocker(req) — Gleam's middleware sugar. Desugars to a callback. Multiple layers compose left-to-right in source order.
  • Return without calling next — returning wisp.response(403) directly in the middleware function short-circuits the pipeline. The inner handler never runs.
  • Result(String, Nil) header access request.get_header returns a Result. Use result.unwrap("") for a safe default. The type system enforces you handle the absent case.
  • robots.txt before the middleware — Pattern match on wisp.path_segments(req) at the top of handle_request before calling any bot-blocking middleware.
  • BEAM VM — Gleam runs on Erlang's OTP runtime. Interops with Elixir and Erlang libraries. Full supervision trees, hot code reloading, and actor model available.

Is your site protected from AI bots?

Run a free scan to check your robots.txt, meta tags, and overall AI readiness score.