Skip to content
Development
Skill

/otp-essentials

Use when writing GenServer, Supervisor, Task, Agent, Registry or ETS code — call vs cast, supervision and restart strategies, process naming, timeouts, async work. Not for LiveView async (use phoenix-liveview-essentials).

BOOST
From plugin
elixir-phoenix-guide
16924 skills3 hooks
Install
$ npx -y skills add j-morgan6/elixir-phoenix-guide --skill otp-essentials --agent claude-code

How it fires

How this skill gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.
  • Slash command/otp-essentials

Context preview

The summary Claude sees to decide when to auto-load this skill.

Use when writing GenServer, Supervisor, Task, Agent, Registry or ETS code — call vs cast, supervision and restart strategies, process naming, timeouts, async work. Not for LiveView async (use phoenix-liveview-essentials).

SKILL.md

otp-essentials.SKILL.md
name: otp-essentials
description: "Use when writing GenServer, Supervisor, Task, Agent, Registry or ETS code — call vs cast, supervision and restart strategies, process naming, timeouts, async work. Not for LiveView async (use phoenix-liveview-essentials)."

OTP Essentials

> Verified against Elixir 1.20, Phoenix 1.8, LiveView 1.2 — October 2026

RULES — Follow these with no exceptions

1. **Always use `@impl true`** before GenServer/Supervisor/Agent callbacks 2. **Keep `init/1` fast** — return `{:ok, state, {:continue, term}}` and do DB/network setup in `handle_continue/2`; a slow `init` blocks the supervisor and app boot 3. **Default to `GenServer.call`** — it gives back-pressure and a 5 s timeout. Use `cast` only for cheap state updates you can afford to lose; send slow side effects (email, HTTP) to `Task.Supervisor` or Oban 4. **Wrap every process behind a public API module** — callers never write `GenServer.call(pid, ...)` themselves 5. **One argument shape: `start_link(opts)` with a keyword list** — read it with `Keyword.fetch!/2`; child specs pass `{MyApp.Room, room_id: id}` 6. **Registry for dynamic processes; atoms only for singletons** — never build a name with `String.to_atom/1` 7. **Every long-running process is supervised** — no bare `spawn`/`Task.start`; inside a LiveView use `start_async/3` or `assign_async/3` 8. **Every `Task.async` is awaited** — for collections use `Task.Supervisor.async_stream_nolink/4` with `on_timeout: :kill_task` 9. **Don't rely on `terminate/2` for cleanup** — on supervisor shutdown it runs only if the process traps exits; use links or monitors for cleanup that must happen

---

GenServer

Public API

defmodule MyApp.Cache do
  use GenServer

  # --- Public API ---

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__))
  end

  def get(server \\ __MODULE__, key), do: GenServer.call(server, {:get, key})

  def put(server \\ __MODULE__, key, value), do: GenServer.call(server, {:put, key, value})

  # --- Callbacks ---

  @impl true
  def init(_opts), do: {:ok, %{}}

  @impl true
  def handle_call({:get, key}, _from, state), do: {:reply, Map.get(state, key), state}

  def handle_call({:put, key, value}, _from, state), do: {:reply, :ok, Map.put(state, key, value)}
end

Fast init with handle_continue

<!-- epg:bad -->

@impl true
def init(_opts) do
  items = MyApp.Repo.all(MyApp.Item)  # blocks the supervisor until the query returns
  {:ok, %{items: items}}
end
@impl true
def init(_opts), do: {:ok, %{items: []}, {:continue, :load}}

@impl true
def handle_continue(:load, state), do: {:noreply, %{state | items: MyApp.Repo.all(MyApp.Item)}}

call vs cast

  • `GenServer.call/3` waits 5 000 ms by default, then **exits the caller** (the server keeps working and its late reply is discarded). Pass a timeout only when you know the work is slower: `GenServer.call(server, msg, to_timeout(second: 30))`.
  • `cast` returns immediately, gives no back-pressure (a flooded mailbox grows without bound), and the message is lost if the server crashes. Fine for `increment`; wrong for "send this email".

<!-- epg:bad -->

# Fire-and-forget through a GenServer: the email is lost if EmailServer crashes
def send_welcome(user), do: GenServer.cast(EmailServer, {:send, user})
# Work that may be lost: a supervised task. Work that must happen: an Oban job.
def send_welcome(user) do
  Task.Supervisor.start_child(MyApp.TaskSupervisor, fn -> MyApp.Mailer.deliver_welcome(user) end)
end

Deferred replies with GenServer.reply/2

Return `{:noreply, state}` from `handle_call/3`, keep `from`, and reply later. The server stays responsive while slow work runs elsewhere:

@impl true
def handle_call({:lookup, key}, from, state) do
  task = Task.Supervisor.async_nolink(MyApp.TaskSupervisor, fn -> slow_lookup(key) end)
  {:noreply, put_in(state.waiting[task.ref], from)}
end

@impl true
def handle_info({ref, result}, state) when is_reference(ref) do
  Process.demonitor(ref, [:flush])
  {from, waiting} = Map.pop!(state.waiting, ref)
  GenServer.reply(from, {:ok, result})
  {:noreply, %{state | waiting: waiting}}
end

def handle_info({:DOWN, ref, :process, _pid, reason}, state) do
  {from, waiting} = Map.pop!(state.waiting, ref)
  GenServer.reply(from, {:error, reason})
  {:noreply, %{state | waiting: waiting}}
end

handle_info and unexpected messages

Timers, monitors, PubSub and task replies arrive in `handle_info/2`. Once you define `handle_info/2`, an unmatched message raises `FunctionClauseError`; add a final clause if stray messages are possible.

require Logger

@impl true
def handle_info(:tick, state) do
  Process.send_after(self(), :tick, 1_000)
  {:noreply, %{state | count: state.count + 1}}
end

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

Expected vs unexpected errors

Reply with `{:error, reason}` for failures the caller can act on; let everything else crash so the supervisor restarts with clean state.

require Logger

@impl true
def handle_call({:charge, order_id}, _from, state) do
  case Payments.charge(order_id) do
    {:ok, receipt} ->
      {:reply, {:ok, receipt}, state}

    {:error, reason} ->
      Logger.error("charge failed: #{inspect(reason)}", order_id: order_id)
      {:reply, {:error, reason}, state}
  end
end

terminate/2

`terminate/2` runs when a callback returns `{:stop, ...}` or raises. On supervisor shutdown it runs **only if the process traps exits** (`Process.flag(:trap_exit, true)` in `init/1`) and its child spec `shutdown` isn't `:brutal_kill`. It never runs on `Process.exit(pid, :kill)` or a VM crash. Put cleanup that must happen in another process that monitors this one.

---

Supervisors

Strategies and restart values

  • `:one_for_one` (default choice): restar
Read more
Ships withelixir-phoenix-guide

· Changelog A Claude Code plugin (with Codex support for the skills) that keeps Claude's Elixir and Phoenix code idiomatic and current.

Get the whole plugin

Other skills on elixir-phoenix-guide.