Gleam

Thinwrap's three Hex packages are written in Erlang and run on the BEAM, so a Gleam project on the Erlang target can depend on them directly. There are no typed Gleam bindings: you declare the functions you call with @external and decode what comes back. The claims below hold for Gleam only.

Gleam constraints

Runtime dependencies
Zero runtime dependencies
BYO HTTP client
transport behaviour: a Gleam module with pub fn request(req, opts), passed as an #(atom, opts) tuple
Provenance
Hex registry signatures + lockfile checksums
Minimum runtime
Gleam 1.14+ on Erlang/OTP 27+, rebar3 on PATH
  • The same Hex package as Erlang, with zero dependencies of its own. There are no typed Gleam bindings: you declare the functions you call with @external, build the atom-keyed config and input as Dynamic (gleam_erlang for atoms) and decode the result.
  • The package is built with rebar3, so Gleam needs rebar3 on PATH to compile it. Erlang target only; the JavaScript target cannot use it.
  • Misuse (an unknown provider, a wrongly typed field) raises rather than returning Error, so it crashes the calling process; operation failures come back as Error.

Setup

Add the packages you need, plus gleam_erlang for the atoms their config and input maps are keyed by. The packages are built with rebar3, so it has to be on PATH when Gleam compiles them; the official ghcr.io/gleam-lang/gleam:*-erlangimages include it.

gleam add thinwrap_location thinwrap_notifications thinwrap_llm gleam_erlang

One call, as a complete module

{ok, X} and {error, Y} are Gleam's Ok(X) and Error(Y) at runtime, so the external returns a Result directly. Config and input are atom-keyed maps built as Dynamic; result fields come back through a decoder. Switching providers changes the config, not the input or the decoders. Prints 35.1 km, 39 min.

import gleam/dynamic.{type Dynamic}
import gleam/dynamic/decode
import gleam/erlang/atom
import gleam/float
import gleam/int
import gleam/io
import gleam/list

// No typed bindings ship with the package: declare what you call.
@external(erlang, "thinwrap_location", "route")
fn route(config: Dynamic, input: Dynamic) -> Result(Dynamic, Dynamic)

// The Erlang API takes atom-keyed maps; these two helpers build them.
fn key(name: String) -> Dynamic {
  atom.to_dynamic(atom.create(name))
}

fn obj(fields: List(#(String, Dynamic))) -> Dynamic {
  fields
  |> list.map(fn(field) { #(key(field.0), field.1) })
  |> dynamic.properties
}

fn point(lat: Float, lng: Float) -> Dynamic {
  obj([#("lat", dynamic.float(lat)), #("lng", dynamic.float(lng))])
}

pub fn main() {
  // OSRM's public demo server needs no key, so this runs as-is.
  let config =
    obj([
      #("provider", key("osrm")),
      #("base_url", dynamic.string("https://router.project-osrm.org")),
    ])
  let input =
    obj([
      #(
        "waypoints",
        dynamic.list([point(52.52, 13.405), point(52.3906, 13.0645)]),
      ),
    ])
  let summary = {
    use meters <- decode.field(
      atom.create("total_distance_meters"),
      decode.float,
    )
    use seconds <- decode.field(
      atom.create("total_duration_seconds"),
      decode.float,
    )
    decode.success(#(meters, seconds))
  }
  let failure = {
    use code <- decode.field(atom.create("code"), atom.decoder())
    use message <- decode.field(atom.create("message"), decode.string)
    decode.success(atom.to_string(code) <> ": " <> message)
  }
  case route(config, input) {
    Ok(result) ->
      case decode.run(result, summary) {
        Ok(#(meters, seconds)) ->
          io.println(
            float.to_string(float.to_precision(meters /. 1000.0, 1))
            <> " km, "
            <> int.to_string(float.round(seconds /. 60.0))
            <> " min",
          )
        Error(_) -> io.println("unexpected result shape")
      }
    Error(err) ->
      case decode.run(err, failure) {
        Ok(text) -> io.println(text)
        Error(_) -> io.println("unexpected error shape")
      }
  }
}

Create atoms from your own literals only, never from user input: atoms are never garbage-collected.

Packages

Baseline-coverage discipline

The unified surface includes only features that ≥90% of providers natively support. Sub-baseline fields stay accessible via thepassthrough key (body deep-merged into the request body; your values win), and the vendor's answer rides inraw. Cross-language parity with the other packages is a design goal — not a hard release gate.

An operation failure is an Error holding a map with acode atom (auth_failed,rate_limited, invalid_request, …) and amessage string. Misusing the API — an unknown key, a wrongly typed value, a provider that doesn't serve the operation — raises instead, before any request, and crashes the calling process. The wrapper holds no state and performs no automatic retry: the rawRetry-After header and its parsed seconds ride in the error's cause. To bring your own HTTP client, write a module with pub fn request(req, opts) and pass it as thetransport tuple.