Elixir OTP skill for Codex

Processes only where state and failure need them. Codex starts by asking whether a process is needed at all, choosing plain functions, Tasks, ETS or Oban before reaching for a GenServer. When it does write one, init stays fast via handle_continue, callers go through a client API, call is the default over cast, and every process sits under a supervisor with a deliberate strategy. Tests use start_supervised and never sleep.

  • Languages
  • $elixir-otp
  • 215 lines
  • 2 files
  • ~2,100 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- elixir-otp

Installs to ~/.agents/skills/elixir-otp/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $elixir-otp, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Designs and debugs Elixir OTP process architecture - when to use a GenServer versus a plain module, Task, Agent or ETS, supervision trees and restart strategies, DynamicSupervisor with Registry, call timeouts, mailbox overload and backpressure - and tests it with ExUnit and start_supervised. Use when adding a GenServer, designing a supervision tree, chasing timeouts, crashes or growing mailboxes, or reviewing Elixir code that spawns processes.

Use it when

  • Adding a GenServer, Agent, Task or ETS table to an Elixir app
  • Designing or reviewing an application supervision tree
  • GenServer.call timeouts, crash loops or growing mailboxes appear
  • You need one process per user, room or device

Use something else when

How to check the result

mix test passes with start_supervised processes, and killing the new process in iex shows the supervisor restarting it into a working state while message_queue_len stays bounded under load.

The mistake it prevents

Routing every request through one named GenServer used as a cache, creating a serial bottleneck that ETS would avoid.

~/.agents/skills/elixir-otp/SKILL.md

--- name: elixir-otp description: Designs and debugs Elixir OTP process architecture - when to use a GenServer versus a plain module, Task, Agent or ETS, supervision trees and restart… license: MIT ---

A good OTP design uses processes only where there is state, concurrency or a failure boundary; everything else is plain functions. Every process is started under a supervisor, can crash and restart into a valid state, and cannot become a bottleneck that every request serialises through.

Before changing code

  1. Read AGENTS.md, mix.exs (Elixir/OTP versions, deps), config/*.exs and the application module (lib/my_app/application.ex) to see the current supervision tree.
  2. Run mix compile --warnings-as-errors and mix test to know the baseline.
  3. Check whether Phoenix, Oban, Broadway or Nebulex are present; they already solve jobs, pipelines and caching and should not be rebuilt as GenServers.

Do you need a process?

NeedUse
Pure transformation, validation, formattingplain module functions
Fire-and-forget or parallel work with resultsTask.Supervisor.async_nolink/2 / Task.async_stream/3
Read-heavy shared data (config, lookup tables, caches)ETS table (read_concurrency: true) owned by a process, or :persistent_term for rarely-changing data
Serialised access to a resource (rate-limited API, port, connection)GenServer
One process per entity (game room, device session, upload)DynamicSupervisor + Registry
Durable background jobs that survive restartsOban (Postgres-backed), not a GenServer
Periodic workGenServer with Process.send_after/3, or Oban cron for at-least-once

A GenServer used as a cache that every request calls is a single-process bottleneck; put the data in ETS and let callers read it directly.

GenServer template

defmodule MyApp.RateLimiter do
  use GenServer
  require Logger

  @refill_ms 1_000

  # Client API: the only functions other modules call
  def start_link(opts) do
    name = Keyword.get(opts, :name, __MODULE__)
    GenServer.start_link(__MODULE__, opts, name: name)
  end

  def acquire(server \\ __MODULE__, timeout \\ 2_000) do
    GenServer.call(server, :acquire, timeout)
  end

  # Server callbacks
  @impl true
  def init(opts) do
    capacity = Keyword.fetch!(opts, :capacity)
    {:ok, %{capacity: capacity, tokens: capacity}, {:continue, :schedule}}
  end

  @impl true
  def handle_continue(:schedule, state) do
    Process.send_after(self(), :refill, @refill_ms)
    {:noreply, state}
  end

  @impl true
  def handle_call(:acquire, _from, %{tokens: 0} = state), do: {:reply, {:error, :rate_limited}, state}
  def handle_call(:acquire, _from, state), do: {:reply, :ok, %{state | tokens: state.tokens - 1}}

  @impl true
  def handle_info(:refill, state) do
    Process.send_after(self(), :refill, @refill_ms)
    {:noreply, %{state | tokens: state.capacity}}
  end

  def handle_info(msg, state) do
    Logger.warning("unexpected message #{inspect(msg)}")
    {:noreply, state}
  end
end

Rules this encodes:

  • Keep init/1 fast. Slow setup (DB loads, connecting) goes in handle_continue/2, otherwise the whole supervisor start blocks and boot can time out.
  • Client functions wrap call/cast; callers never send raw messages.
  • Once you define any handle_info/2 clause, add a catch-all: an unmatched message raises FunctionClauseError and crashes the server.
  • Never do slow I/O inside handle_call for a shared server; offload to a Task and reply later with GenServer.reply/2, returning {:noreply, state} meanwhile.

call vs cast

callcast
Backpressureyes, caller waitsnone; mailbox can grow without bound
Failure visibilitycaller gets exit on timeout or crashsilent
Default timeout5000 msn/a

Default to call. Use cast only when the sender truly must not wait and the rate is bounded. A timed-out call exits the caller; catching it is rarely right, and the server may still process the request, so make the operation idempotent.

Supervision trees

def start(_type, _args) do
  children = [
    MyApp.Repo,
    {Registry, keys: :unique, name: MyApp.SessionRegistry},
    {DynamicSupervisor, name: MyApp.SessionSupervisor, strategy: :one_for_one},
    {Task.Supervisor, name: MyApp.TaskSupervisor},
    {MyApp.RateLimiter, capacity: 50},
    MyAppWeb.Endpoint
  ]

  Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
end
StrategyWhen
:one_for_onechildren independent (default)
:rest_for_onelater children depend on earlier ones (e.g. a registry, then its users)
:one_for_allchildren only valid together; restart all if one dies
  • Order matters: children start top to bottom and stop in reverse. Start dependencies (Repo, Registry, PubSub) before their users.
  • restart: :permanent (default) for servers, :transient for work that may finish normally, :temporary for processes never restarted. Set via use GenServer, restart: :transient or in the child spec.
  • Restart intensity defaults to 3 restarts in 5 seconds, then the supervisor itself dies. Do not raise limits to hide a crash loop; fix the crash.
  • Put unstable parts (external connections) in their own subtree so their crashes do not restart stable siblings.

Per-entity processes

def start_session(id) do
  spec = {MyApp.Session, id: id, name: {:via, Registry, {MyApp.SessionRegistry, id}}}
  case DynamicSupervisor.start_child(MyApp.SessionSupervisor, spec) do
    {:ok, pid} -> {:ok, pid}
    {:error, {:already_started, pid}} -> {:ok, pid}
    other -> other
  end
end

Registry is node-local. For clustered uniqueness you need :global, Horde or a different design; say so instead of assuming Registry covers it.

Let it crash, correctly

  • Crash on programmer errors and impossible states; handle expected errors ({:error, :not_found}) as values.
  • State that must survive a crash belongs outside the process (DB, ETS owned by a supervisor-level process, or recomputed in init).
  • Use Process.flag(:trap_exit, true) only when the process must run terminate/2 cleanup on shutdown; set shutdown: in the child spec to give it time.
  • Monitor (Process.monitor/1) rather than link when you only need to know a process died.

Testing

test "rejects when tokens are exhausted" do
  pid = start_supervised!({MyApp.RateLimiter, capacity: 1, name: nil})
  assert :ok = MyApp.RateLimiter.acquire(pid)
  assert {:error, :rate_limited} = MyApp.RateLimiter.acquire(pid)
end
  • start_supervised!/1 ties the process to the test and cleans it up. Allow name: nil (or unique names) so async: true tests do not collide.
  • Test the client API, not handle_call directly.
  • For timing, send the message yourself (send(pid, :refill)) instead of sleeping.
  • Use assert_receive with a timeout for async messages; never Process.sleep to wait.

Diagnosing a live system

:sys.get_state(MyApp.RateLimiter)                     # inspect state (debug only)
Process.info(pid, [:message_queue_len, :memory, :current_function])
Supervisor.which_children(MyApp.Supervisor)
:observer.start()                                     # GUI; needs wx, local only

A message_queue_len that keeps growing means producers outpace the server: switch to call, shard by key (PartitionSupervisor), move reads to ETS, or add a demand-driven pipeline (GenStage/Broadway). Attaching a remote shell to production is outward-facing; confirm with the user first and keep to read-only inspection.

Verification

  • mix compile --warnings-as-errors, mix format --check-formatted, mix test.
  • mix credo --strict and mix dialyzer if the project has them.
  • Kill the new process in iex -S mix (Process.exit(pid, :kill)) and confirm the supervisor restarts it into a working state.
  • Under load (a quick Task.async_stream loop), message_queue_len stays bounded.

Gotchas

  • A named GenServer is a global singleton; two tests or two tenants sharing it will interfere.
  • Large binaries and big maps in state are copied on every call reply; return only what the caller needs.
  • Task.async links to the caller; a crash in the task kills the caller. Use Task.Supervisor.async_nolink when failure is acceptable.
  • GenServer.call(self(), ...) from inside the same server deadlocks until timeout.
  • ETS tables die with their owner process; create them in a long-lived process.

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Elixir OTP"
  short_description: "Processes only where state and failure need them"
  default_prompt: "$elixir-otp Add a GenServer that rate-limits calls to the payment API"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem