AI agent skill · by @mizchi
Gleam Practice
Use when writing or reviewing Gleam code on the Erlang target — especially Wisp + Mist HTTP services, OTP-based processes (gen_server / supervisor analogues), justfile workflows, gleeunit testing, `gleam format`, GitHub Actions CI, and performance measurement. Trigger on `gleam.toml`, `.gleam` files, or Erlang/BEAM-related symptoms even if the user does not say "Gleam" by name.
Archived 2026-10-23 · Source updated 2026-09-16. Check the original repository for the latest version.
SKILL.md instructions
# Gleam Practice
Use this when creating a new Gleam project, improving an existing one, or implementing with a `wisp + mist + gleam_otp + just` stack.
## Default Workflow
- Start with `gleam new` and `gleam add`. Let the solver choose dependencies instead of hand-writing them.
- Proceed TDD-style: `explore -> Red -> Green -> Refactor`.
- Separate pure logic from state management. Split domain modules from actor/server modules.
- Keep the public API small. Prefer `pub opaque type` and avoid casually exposing internal constructors or protocol types.
- Treat `@external` as a last resort. When you do use it, contain it inside a thin adapter module.
- Keep the web layer thin. `wisp` handlers should focus on decode, call service, encode response.
- Centralize task execution in a `justfile`, and have CI invoke `just ci` too.
## Project Setup
Use this as the starting point for new Erlang-target projects.
```sh
gleam new my_app --template erlang
cd my_app
gleam add wisp mist gleam_otp gleam_erlang
gleam add gleam_json envoy
gleam add --dev gleeunit
```
Add the following as needed.
- HTTP client: `gleam add gleam_http gleam_httpc`
- file I/O: `gleam add simplifile filepath`
- snapshot test: `gleam add --dev birdie`
- property test: `gleam add --dev qcheck qcheck_gleeunit_utils`
- interactive test loop: `gleam remove gleeunit && gleam add --dev glacier`
- unused export check: `gleam add --dev cleam`
- benchmark: `gleam add --dev glychee`
- logging config: `gleam add logging`
- live reload DX: `gleam add --dev olive`
External CLI:
- HTTP load test: `k6`
Use compatible combinations of `wisp` and `mist`. Rather than pinning versions individually, it is safer to run `gleam add wisp mist` together and let the solver resolve them at the same time.
Templates:
- `assets/README.md`
- `assets/justfile`
- `assets/github-actions/ci.yml`
- `assets/bench/http.js`
- `assets/test/snapshot_test.gleam`
- `assets/test/property_test.gleam`
- `assets/test/qcheck_parallel_test.gleam`
- `assets/test/timeout_test.gleam`
## gleam.toml Defaults
At minimum, fill these in.
```toml
name = "my_app"
version = "0.1.0"
target = "erlang"
description = "Short package summary"
licences = ["Apache-2.0"]
repository = { type = "github", user = "owner", repo = "repo" }
# Hide modules that are only used inside the package
internal_modules = [
"my_app/internal",
"my_app/http/internal",
]
```
Principles:
- Do not leave metadata empty.
- Hide package-internal modules via `internal_modules`.
- Broad semver ranges are acceptable, but always enforce a pinned lockfile in CI.
## Recommended Layout
Start from this split.
```text
src/
my_app.gleam # entrypoint
my_app/app.gleam # DI and supervision startup
my_app/web.gleam # router / request handling
my_app/domain.gleam # pure domain logic
my_app/domain_server.gleam # actor wrapper
my_app/http_json.gleam # encoder / decoder
test/
my_app_test.gleam
docs/
reference-ja.md
reference-en.md
bench/
main.gleam
justfile
```
Separation criteria:
- `domain.gleam`: pure functions only
- `*_server.gleam`: actor / supervision / timeout / mailbox
- `web.gleam`: routing and HTTP mapping
- `http_json.gleam`: JSON schema and codec
- `app.gleam`: wiring only
Do not mix router, JSON codec, business logic, and actor state in one file.
As it grows, split along these lines.
- `web_*.gleam`: handlers per feature
- `web_json_*.gleam`: encoders / decoders per domain
- `agent_*.gleam`: protocol, runner, tool, transport
- `workspace_*.gleam`: overlay, patch, git, session, runtime
- `*_test_support.gleam`: split test helpers by responsibility
## Wisp + Mist Skeleton
`src/my_app.gleam`
```gleam
import envoy
import gleam/erlang/process
import gleam/int
import gleam/result
import mist
import my_app/app
import my_app/web
import wisp/wisp_mist
const default_port = 4000
const default_secret_key_base =
"local-dev-secret-key-base-local-dev-secret-key-base-1234567890"
pub fn main() -> Nil {
let port =
envoy.get("PORT")
|> result.map(int.parse)
|> result.flatten
|> result.unwrap(default_port)
let secret_key_base =
envoy.get("SECRET_KEY_BASE")
|> result.unwrap(default_secret_key_base)
let assert Ok(app_state) = app.start()
let assert Ok(_) =
web.app(app_state)
|> wisp_mist.handler(secret_key_base)
|> mist.new
|> mist.bind("0.0.0.0")
|> mist.port(port)
|> mist.start
process.sleep_forever()
}
```
`src/my_app/web.gleam`
```gleam
import gleam/http
import my_app/app.{type App}
import wisp
pub fn app(app: App) -> fn(wisp.Request) -> wisp.Response {
fn(request) {
case request.method, wisp.path_segments(request) {
http.Get, ["healthz"] -> wisp.text_response(200, "ok\n")
_, _ -> wisp.not_found()
}
}
}
```
Web-layer principles:
- Read payloads with `wisp.require_json`.
- Return `400` on decoder failure.
- Map domain errors explicitly to `4xx/5xx`.
- Split handlers by feature once they grow.
## OTP Pattern
The basic shape is two layers: `pure state` and `server`.
**Important**: `gleam_otp` is a statically-typed wrapper over Erlang's `gen_server`, and **its API is not compatible with `gen_server`**. Do not call `:gen_server.call` / `:gen_server.cast` directly. Keep everything inside the `Subject` and `actor.Message` world.
### pure state
```gleam
pub opaque type State {
State(hits: Dict(String, Int), limit: Int)
}
pub fn new(limit: Int) -> State {
State(hits: dict.new(), limit: limit)
}
pub fn incr(state: State, key: String) -> #(State, Bool) {
let n = dict.get(state.hits, key) |> result.unwrap(0) + 1
#(State(..state, hits: dict.insert(state.hits, key, n)), n <= state.limit)
}
```
### actor wrapper (concrete example)
```gleam
import gleam/erlang/process.{type Subject}
import gleam/otp/actor
import gleam/otp/supervision
pub opaque type Message {
Check(key: String, reply_to: Subject(Bool))
Reset(key: String)
}
pub opaque type Server { Server(subject: Subject(Message)) }
pub fn start(limit: Int) -> Result(Server, actor.StartError) {
actor.new(new(limit))
|> actor.on_message(handle)
|> actor.start
|> result.map(fn(started) { Server(subject: started.data) })
}
pub fn supervised(limit: Int) -> supervision.ChildSpecification(Server) {
supervision.worker(fn() { start(limit) })
}
fn handle(state: State, msg: Message) -> actor.Next(State, Message) {
case msg {
Check(key, reply_to) -> {
let #(next, ok) = incr(state, key)
process.send(reply_to, ok)
actor.continue(next)
}
Reset(key) ->
actor.continue(State(..state, hits: dict.delete(state.hits, key)))
}
}
// sync call pattern: the caller creates a reply subject and passes it in
pub fn check(s: Server, key: String) -> Bool {
let reply = process.new_subject()
process.send(s.subject, Check(key, reply))
process.receive(reply, 1000) |> result.unwrap(False)
}
pub fn reset(s: Server, key: String) -> Nil {
process.send(s.subject, Reset(key))
}
```
### Three kinds of supervision trees
`gleam_otp` splits supervisors across three modules by use case.
| Kind | Module | When to use |
|---|---|---|
| **static** | `gleam/otp/static_supervisor` | Child processes are fixed at startup (DB pool, pre-configured actors). Handles restarts only. |
| **factory** | `gleam/otp/supervisor_factory` | Spawn the same spec dynamically with IDs (e.g. session-per-user). `start_child(name, arg)` |
| **dynamic** | `gleam/otp/supervisor_dynamic` | Add/remove arbitrary specs dynamically (e.g. job worker pool). `start_child(name, spec)` / `terminate_child` |
The exact API names vary by version. Check the official `gleam_otp` hex docs each time. The template below assumes v0.16+:
```gleam
// src/my_app/app.gleam
import gleam/otp/static_supervisor as sup
import my_app/db_pool
import my_app/rate_limiter
pub fn start() -> Result(Nil, actor.StartError) {
sup.new(sup.OneForOne)
|> sup.restart_tolerance(intensity: 3, period: 60) // allow up to 3 restarts in 60s
|> sup.add(db_pool.supervised()) // static child
|> sup.add(rate_limiter.supervised(limit: 100)) // static child
|> sup.start
|> result.replace(Nil)
}
```
How to pick a restart strategy:
- **`Permanent`**: always restart on exit (infrastructure like DB pools or auth servers).
- **`Transient`**: normal exits are fine, restart only on abnormal exits (job workers).
- **`Temporary`**: never restart (one-shot work).
When `restart_intensity` (default 3) over `period` (default 5 seconds) is exceeded, the supervisor itself crashes and propagates upward.
### Guidelines
- Keep `Server` opaque (do not let callers manipulate the Subject directly).
- Do not touch state directly from HTTP (always go through the actor).
- Collect the supervision tree inside `app.gleam`.
- Start with `OneForOne` as the restart strategy, and use `static_supervisor` at the top level.
- Use named actors to make testing easier.
- Use the right supervisor kind for the job: `static` (fixed), `factory` (same spec, dynamic), `dynamic` (arbitrary spec, dynamic).
## JSON Codec (`gleam/dynamic/decode` + `gleam/json`)
Gleam's JSON API tends to shift between versions (`gleam_json` v2+). Current idiomatic usage:
### Decode
```gleam
import gleam/dynamic/decode
import gleam/json
pub type NewTodo { NewTodo(title: String, priority: Int) }
pub fn new_todo_decoder() -> decode.Decoder(NewTodo) {
use title <- decode.field("title", decode.string)
use priority <- decode.optional_field("priority", 0, decode.int)
decode.success(NewTodo(title:, priority:))
}
// Inside a Wisp handler
case decode.run(body, new_todo_decoder()) {
Ok(nt) -> // ... use nt
Error(errors) -> wisp.bad_request("invalid json: " <> string.inspect(errors))
}
```
`decode.field` is required; `decode.optional_field(key, default, decoder)` is optional. For nested objects use `decode.field("user", user_decoder())`.
### Encode
```gleam
pub fn encode_todo(t: Todo) -> json.Json {
json.object([
#("id", json.int(t.id)),
#("title", json.string(t.title)),
#("done", json.bool(t.done)),
])
}
// Return it from Wisp
wisp.json_response(json.to_string_tree(encode_todo(t)), 201)
```
Lists: `json.array(items, of: encode_todo)`.
**LSP Code Action**: `gleam-language-server` v1.2+ ships a "Generate JSON encoder/decoder" feature. Place the cursor on the `Todo` type and invoke the Code Action to auto-generate `encode_todo` / `todo_decoder`. More accurate than writing them by hand.
## justfile Template
For reuse, first copy `assets/justfile` into the project root, then tweak.
```just
set shell := ["bash", "-cu"]
default:
@just --list
deps:
gleam deps download
format:
gleam format
format-check:
gleam format --check .
typecheck:
gleam check
build:
gleam build --warnings-as-errors
test:
gleam test
run:
gleam run
docs:
gleam docs build
check:
gleam format --check .
gleam check
gleam build --warnings-as-errors
gleam test
ci: deps check
clean:
gleam clean
bench *args:
gleam run -m bench/main -- {{args}}
```
Policies:
- Shell is `bash`.
- The CI entrypoint is `just ci`.
- Do not create a dedicated `lint` command; cover it with `format-check + build --warnings-as-errors`.
Add these if needed.
- `exports: gleam run -m cleam`
- `snapshot-review: gleam run -m birdie`
- `test-watch: gleam test -- --glacier`
- `bench-http: k6 run bench/http.js`
- `serve-dev: gleam run -m olive`
## CI Workflow Template
If you use GitHub Actions, first copy `assets/github-actions/ci.yml` to `.github/workflows/ci.yml` and only add project-specific steps on top.
Principles:
- Make `just ci` the single entrypoint for both CI and local.
- Pin Erlang / Gleam versions on the workflow side.
- Add extra toolchains only when you have `@external`, NIF, Wasm, or Elixir dependencies.
- Do not pile shell scripts into the workflow; push logic down into the `justfile`.
## Testing
Lock down pure functions first, then actors, then finally HTTP.
`test/my_app_test.gleam`
```gleam
import gleam/http
import gleeunit
import my_app/app
import my_app/web
import wisp/simulate
pub fn main() -> Nil {
gleeunit.main()
}
pub fn healthz_test() {
let assert Ok(app_state) = app.start()
let response = web.app(app_state)(simulate.request(http.Get, "/healthz"))
assert response.status == 200
assert simulate.read_body(response) == "ok\n"
}
```
Testing policy:
- Test state transitions of pure modules directly.
- For actor modules, test only the public API.
- Prefer `wisp/simulate` for HTTP tests.
- Inject fake services for external APIs.
- Use poll/retry helpers instead of flaky sleeps.
- Use `birdie` for outputs where snapshots fit.
- Use `qcheck` for pure logic where property-based tests fit.
- Use `glacier` when you want faster local iteration.
Basic `birdie` workflow:
1. `gleam test`
2. Check failing / new snapshots.
3. `gleam run -m birdie`
4. Review snapshots and commit.
Basic `qcheck` workflow:
1. Pick one property of a pure function.
2. Feed it generators through `qcheck.given(...)`.
3. When a counterexample appears, use the shrunk result to write a unit test.
4. For long-running tests or parallel execution, use `qcheck_gleeunit_utils`.
Notes on `qcheck_gleeunit_utils`:
- Erlang-target only.
- To parallelize all tests together, use `run.run_gleeunit`.
- To wrap only one long test, use `test_spec.make`.
- To attach a custom timeout, use `test_spec.make_with_timeout`.
- To make test groups explicit, use `test_spec.run_in_parallel` / `run_in_order`.
## Test Structure
Once features grow, split test files by responsibility.
- `domain_test.gleam`: pure domain logic
- `runtime_test.gleam` / `app_test.gleam`: supervision and actor integration
- `web_app_test.gleam`: HTTP contract
- `agent_test.gleam`: external API orchestration
- `workspace_session_server_test.gleam`: server / state helpers
- `workspace_bit_runtime_test.gleam`: FFI / git / wasm integration
- `workspace_session_http_test.gleam`: session API contract
Split the support modules too.
- `*_app_test_support.gleam`: app startup, handler construction, runtime helpers
- `*_workspace_test_support.gleam`: fixture directories, workspace helpers
Do not cram everything into a single giant test file. As refactoring proceeds, split the tests along with it.
## Lint and Quality
Gleam fits better with strict use of the formatter and compiler warnings than with a separate first-party lint tool.
Minimum:
- `gleam format --check .`
- `gleam check`
- `gleam build --warnings-as-errors`
- `gleam test`
Supplementary:
- Track deprecated APIs: `gleam fix`
- Check docs: `gleam docs build`
- Detect unused exports: `gleam run -m cleam`
## Performance Measurement
Measure in three tiers.
1. Micro benchmarks of pure functions.
2. Integration benchmarks for actors / workspaces.
3. Load tests against HTTP endpoints.
Principles:
- Separate pure functions from I/O as benchmark targets.
- Separate warmup from measurement.
- Turn off debug logging.
- Run `gleam clean` and pre-download dependencies before benchmarking.
- Do not compare micro benchmark numbers against HTTP load-test numbers on equal footing.
- For FFI / Wasm / Ports, measure cold start and hot path separately.
Tools:
- Micro benchmarks: create a `bench/` module and run it with `gleam run -m bench/main`.
- If you want a library, use `glychee`.
- HTTP load tests: `k6` or `wrk`.
- BEAM hotspots: from `erl` / `gleam shell`, use `:eprof`, `:fprof`, `:erlang.statistics(:reductions)`.
Minimal HTTP measurement example:
```sh
just run
k6 run bench/http.js
```
Start by copying `assets/bench/http.js` to `bench/http.js`.
## FFI and Native Integration
When you use `@external`, stick to these rules.
- Create a single adapter module.
- Expose only `pub opaque type Handle`.
- Do not leak too many Erlang/Elixir types or pids.
- Keep the details of paths, ETS, NIF, Port, and Wasm runtimes inside internal modules.
- Write both live tests and failure tests.
The compiler does not validate `@external` implementations, so minimize the Gleam-side surface area.
## Additional Tools
Think of frequently used external tools as follows.
- `gleeunit`: the default unit test runner. Start here.
- `birdie`: snapshot tests. Good for pinning HTTP responses and generated text.
- `qcheck` + `qcheck_gleeunit_utils`: property-based testing. Good for pure domain logic.
- `glacier`: interactive / incremental test loop. Use as a drop-in replacement for `gleeunit`.
- `cleam`: detect unused exports. Good for pruning the public API.
- `glychee`: micro benchmarks. Good for comparing pure functions and small integrations.
- `k6`: HTTP / WebSocket load testing. An external CLI, not a Gleam package.
- `logging`: configuration for Erlang's logger. Reasonable to add for ops-heavy projects.
- `olive`: dev proxy with live reload. Only bother when you want to improve the Wisp/Mist dev experience.
Selection:
- Default: `gleam format/check/build/test`.
- First additions worth considering: `birdie`, `qcheck`, `cleam`, `glychee`.
- `glacier` and `olive`: when you want to improve local DX.
- `logging`: for long-running services or production apps.
## Documentation
The README should at least cover:
- What is implemented and what is explicitly out of scope.
- The main module prefixes and their responsibilities.
- How to start, `just ci`, and the main endpoints.
- How to read the test files.
For mid-sized or larger projects, add a `docs/` directory and guide readers through it.
- `docs/reference-ja.md`: Japanese implementation guide.
- `docs/reference-en.md`: English implementation guide.
State clearly at the top of the README whether this project is "introductory" or a "practical reference implementation".
## Reference Project Positioning
When presenting a Gleam project as a reference implementation, do not leave its positioning vague.
- beginner sample: a small API with minimal OTP.
- medium reference: `wisp + mist + gleam_otp + just + CI + docs`.
- advanced integration: FFI, Wasm, external LLM, workspace/session orchestration.
When you include advanced material, explain separately whether each part is "standard Gleam style" or "complexity specific to this project".
## Closure Checklist
Good checkpoints for calling a cut "done":
- The public / internal boundary is organized.
- `just ci` is green.
- Test files are split per feature.
- Support modules are split by responsibility.
- The README covers implementation status and test layout.
- If needed, `docs/` contains a reference guide.
## Review Checklist
- Are the public constructors truly necessary?
- Could `pub type` be made `pub opaque type`?
- Are there modules that should be hidden in `internal_modules`?
- Is the web layer doing more than decode/call/encode?
- Is pure logic mixed with actor state?
- Is `just ci` the shared entrypoint for local and CI?
- Is `gleam build --warnings-as-errors` passing?
- Are you baking in absolute paths or machine-local config?
- Is the external/FFI boundary too wide?
## References
- Gleam docs: https://gleam.run/
- Gleam `gleam.toml`: https://gleam.run/writing-gleam/gleam-toml/
- Gleam externals: https://gleam.run/documentation/externals/
- Gleam CLI: https://gleam.run/reference/cli/
- Wisp docs: https://hexdocs.pm/wisp/
- Mist docs: https://hexdocs.pm/mist/
- just manual: https://just.systems/man/en/