Phoenix and LiveView skill for Codex
Contexts, streams and PubSub without data leaks. Codex keeps Phoenix web code behind context functions that take the current scope, so every query is filtered by owner. LiveViews hold collections in streams, load slow data with assign_async, subscribe only once connected, and re-check ownership on every event. Broadcasts happen after commit, channel joins are authorized, and tests cover both the context and the LiveView.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- phoenix-elixir Installs to ~/.agents/skills/phoenix-elixir/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install phoenix-elixir The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project phoenix-elixir Installs to .agents/skills/phoenix-elixir/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/phoenix-elixir.zipunzip -o phoenix-elixir.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) phoenix-elixir PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $phoenix-elixir, 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
Builds Phoenix 1.7 and 1.8 applications in Elixir - context modules as the boundary around Ecto schemas, changesets and Ecto.Multi, LiveView 1.x with streams, assign_async and handle_params, PubSub broadcasts, channels with join authorization and Presence, scopes from phx.gen.auth, and ExUnit tests with ConnCase, DataCase and Phoenix.LiveViewTest. Use when adding a feature to a Phoenix app, writing a LiveView, wiring real-time updates, fixing N+1 preloads or double queries on mount, or testing LiveViews and channels.
Use it when
- Adding a feature, context or LiveView to a Phoenix 1.7 or 1.8 application
- Wiring real-time updates with PubSub, channels or Presence
- LiveView memory, double mount queries or N+1 preloads hurt performance
- Testing contexts, controllers, LiveViews or channels with ExUnit
Use something else when
- The question is GenServer, supervision tree or OTP design: use elixir-otp
- You need a plain WebSocket design guide independent of Phoenix: use websockets-realtime
How to check the result
mix compile with warnings as errors and mix test pass, including a LiveView test that drives the UI event and a test proving another user's records are not reachable.
The mistake it prevents
Running expensive queries or PubSub subscriptions directly in mount, which executes twice per page load and subscribes on the static render.
Done well means: web modules call context functions and never Repo directly, every
write goes through a changeset, LiveViews keep large collections in streams and do their
expensive work only once, real-time updates go through PubSub with authorization on
subscribe, and tests cover the context and the LiveView or controller separately.
Before changing anything
- Read
AGENTS.md(Phoenix 1.8 generates one with project rules) andmix.exsfor the Phoenix, LiveView and Ecto versions. - Look at
lib/<app>/(contexts) andlib/<app>_web/(router, live, controllers, components). Note whetherphx.gen.authscopes are in use (current_scopeassigns). - Baseline:
mix deps.get(network),mix compile --warnings-as-errors,mix test. In 1.8 projects prefer the generatedmix precommitalias if it exists.
Contexts
A context is the public API for one area of the domain. Generators give a correct start:
mix phx.gen.context Catalog Product products name:string price:decimal
mix phx.gen.live Catalog Product products name:string price:decimal # context + LiveViews
mix phx.gen.json Catalog Product products name:string --no-context # reuse existing context
defmodule Shop.Orders do
import Ecto.Query
alias Shop.Repo
alias Shop.Orders.{Order, LineItem}
def list_orders(%Scope{user: user}) do
Order
|> where(user_id: ^user.id)
|> order_by(desc: :inserted_at)
|> preload(:line_items)
|> Repo.all()
end
def place_order(%Scope{} = scope, attrs) do
Ecto.Multi.new()
|> Ecto.Multi.insert(:order, Order.changeset(%Order{user_id: scope.user.id}, attrs))
|> Ecto.Multi.run(:stock, fn repo, %{order: order} -> reserve_stock(repo, order) end)
|> Repo.transact()
|> case do
{:ok, %{order: order}} ->
broadcast(scope, {:order_placed, order})
{:ok, order}
{:error, :order, changeset, _} -> {:error, changeset}
{:error, :stock, reason, _} -> {:error, reason}
end
end
end
Repo.transact/2 (Ecto 3.13+) takes a Multi or a function returning {:ok, _} /
{:error, _}; on older Ecto use Repo.transaction/1. For a fixed sequence of steps a
plain function inside Repo.transact reads better than a Multi.
- Pass the scope (or current user) as the first argument so every query is filtered by ownership. A query without a user or tenant filter in a multi-tenant app is a data leak.
- Changesets:
castonly the fields the user may set,validate_*, and the matching*_constraint(unique_constraint(:email)) so database errors become changeset errors. - Preload in the context (
preload: [:line_items]), never lazily in templates; Ecto raisesEcto.Association.NotLoadederrors instead of silently querying, which is good. - Broadcast after commit, never inside the transaction.
LiveView
Lifecycle: mount/3 runs twice (once for the static HTTP render, once when the socket
connects), then handle_params/3, then events.
defmodule ShopWeb.OrderLive.Index do
use ShopWeb, :live_view
alias Shop.Orders
@impl true
def mount(_params, _session, socket) do
scope = socket.assigns.current_scope
if connected?(socket), do: Orders.subscribe(scope)
{:ok,
socket
|> assign(:page_title, "Orders")
|> stream(:orders, Orders.list_orders(scope))
|> assign_async(:stats, fn -> {:ok, %{stats: Orders.stats(scope)}} end)}
end
@impl true
def handle_info({:order_placed, order}, socket) do
{:noreply, stream_insert(socket, :orders, order, at: 0)}
end
@impl true
def handle_event("cancel", %{"id" => id}, socket) do
scope = socket.assigns.current_scope
case Orders.cancel_order(scope, id) do
{:ok, order} -> {:noreply, stream_insert(socket, :orders, order)}
{:error, reason} -> {:noreply, put_flash(socket, :error, "Could not cancel: #{inspect(reason)}")}
end
end
end
<div id="orders" phx-update="stream">
<div :for={{dom_id, order} <- @streams.orders} id={dom_id}>
{order.reference} <button phx-click="cancel" phx-value-id={order.id}>Cancel</button>
</div>
</div>
<.async_result :let={stats} assign={@stats}>
<:loading>Loading stats...</:loading>
{stats.total} orders
</.async_result>
| Situation | Use |
|---|---|
| List that grows or updates in place | stream/3 + stream_insert / stream_delete (not a list assign) |
| Slow data that should not block first paint | assign_async/3 or start_async/3 |
| State that belongs in the URL (filters, page, tab) | push_patch + handle_params/3 |
| Form | to_form(changeset), phx-change="validate" with action: :validate, phx-submit |
| Subscription | if connected?(socket); never subscribe on the static render |
- Treat every
handle_eventpayload as untrusted: re-check ownership through the scope. A hiddenphx-value-idcan be edited in the browser. - Do not store large structs in assigns; each assign lives in the LiveView process memory for the whole session.
- HEEx in 1.7.14+/LiveView 1.0 accepts
{expr}interpolation in bodies and attributes; older templates use<%= %>. Match the project.
Channels and PubSub
def subscribe(%Scope{user: user}), do: Phoenix.PubSub.subscribe(Shop.PubSub, "orders:user:#{user.id}")
defp broadcast(%Scope{user: user}, msg), do: Phoenix.PubSub.broadcast(Shop.PubSub, "orders:user:#{user.id}", msg)
For non-LiveView clients (mobile, other SPAs), channels:
def join("room:" <> room_id, _params, socket) do
if Chat.member?(socket.assigns.user_id, room_id),
do: {:ok, assign(socket, :room_id, room_id)},
else: {:error, %{reason: "unauthorized"}}
end
Authenticate in UserSocket.connect/3 (verify a Phoenix.Token from params), authorize
in every join/3. Use Phoenix.Presence for who-is-online instead of hand-rolled ETS.
Testing
test "cancelling an order updates the row", %{conn: conn, scope: scope} do
order = order_fixture(scope)
{:ok, view, _html} = live(conn, ~p"/orders")
assert view |> element("#orders-#{order.id} button", "Cancel") |> render_click() =~ "cancelled"
end
- Context tests in
DataCase(sandboxed,async: truewhere possible); web tests inConnCase. - Test that a second user cannot read or mutate the first user’s records through the context and through the LiveView event.
- Async assigns:
render_async(view)before asserting on their content.
Checklist
- Web layer calls contexts only; contexts take scope or user first
- Changesets cast an allow-list and declare constraints
- Collections in streams; subscriptions only when connected
- Broadcast after commit; channel joins authorized
-
mix format --check-formatted,mix compile --warnings-as-errors,mix testpass
Gotchas
- Expensive queries in
mount/3run twice per page load; move them behindconnected?/1,assign_async, orhandle_paramsas appropriate. Repo.preloadinsideEnum.mapis N+1; preload the list once.- Runtime config belongs in
config/runtime.exs; values inconfig/prod.exsare baked in at compile time and ignore environment variables set at boot. - Running
mix ecto.resetor migrations against anything but a local database drops or alters real data; confirm with the user first.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Phoenix and LiveView"
short_description: "Contexts, streams and PubSub without data leaks"
default_prompt: "$phoenix-elixir Add an orders page to our Phoenix app with live updates when a new order arrives" 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.