mix phx.new gives you a working Phoenix app and a 448-line AGENTS.md. That file is genuinely good advice, and it is the wrong shape: every agent pays for all 448 lines on every task, whether it's renaming a CSS class or writing an Ecto migration. Meanwhile mix precommit runs four steps, nothing checks that your agent guidance still matches your code, and a fresh git worktree lands without your local secrets.
This guide closes those gaps. Six steps, about 30 minutes, done once per project. At the end an agent working in your repo loads only the conventions its current task needs, and a single mix precommit catches formatting, dead deps, stale translations, style, security, and drift.
In a hurry? PROMPT.md is the whole guide as one prompt. Paste it into Claude Code (or any coding agent) inside a fresh Phoenix app and it will do all six steps and verify each one. Copy-paste-ready files live in templates/.
Create .tool-versions at the repo root:
erlang 28.3
elixir 1.20.0-otp-28
Then raise the floor in mix.exs to match:
elixir: "~> 1.20",mix phx.new writes ~> 1.15, which is a floor, not a pin — two developers can both satisfy it and still compile against different OTP releases. .tool-versions is read by both asdf and mise, and CI actions can read it directly (version-file: .tool-versions, version-type: strict), so the version lives in one place.
Switching OTP major versions invalidates _build. After changing these, run rm -rf _build && mix deps.get && mix compile. Skip it and you get confusing "module not available" errors from beams compiled against the old runtime.
Phoenix generates a four-step alias. Replace it:
precommit: [
"compile --warnings-as-errors",
"skills.check",
"deps.unlock --unused",
"format",
"gettext.extract --check-up-to-date",
"credo --strict",
"sobelow",
"test"
]Add the two tools:
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:sobelow, "~> 0.14", only: [:dev, :test], runtime: false}Why this matters more for agents than for you: an agent can't tell whether it's finished. A single command that exits non-zero on anything wrong is the only reliable "done" signal you can give it. Put mix precommit in AGENTS.md as the definition of done and the agent will self-correct instead of handing you broken code.
Order is deliberate — cheap and structural checks first, test last, so failures surface in seconds rather than after a full suite run.
Three things will fail on a fresh scaffold. All three are one-time fixes, and hitting them now beats hitting them mid-feature:
| Failure | Fix |
|---|---|
gettext.extract --check-up-to-date — default.pot doesn't exist |
mix gettext.extract --merge once, then commit the generated .pot / .po files |
credo --strict — Phoenix.LiveView.JS not alphabetically ordered in <app>_web.ex |
Swap the two alias lines. Phoenix's own generated code trips its own check |
sobelow — Config.CSP: Missing Content-Security-Policy |
Real finding. Either implement CSP or ignore it explicitly, with a reason (below) |
Generate a credo config with mix credo.gen.config. The default is sensible; the one change worth making is disabling Design.AliasUsage, which otherwise nags you to alias every module you reference twice.
.sobelow-conf:
[
verbose: false,
private: false,
skip: false,
router: "lib/my_app_web/router.ex",
exit: "Low",
format: "txt",
# Config.CSP is a known gap. CSP for LiveView needs script nonces and
# careful handling of inline event handlers — tracked as separate work.
ignore: ["Config.CSP"]
]exit: "Low" means any finding at all fails the build. That's the point: an ignore list with written reasons is a decision you can audit later, while a permissive threshold is a decision nobody made.
This is the step that actually changes how agents behave.
The rule: AGENTS.md holds only what is always true and can't be inferred from the code. Everything task-specific becomes a skill under .claude/skills/<name>/SKILL.md, which loads by trigger. Anything an agent could learn by reading the code in 30 seconds gets deleted, not documented.
Three questions per block of text:
- Does an agent need this on every task? →
AGENTS.md - Only when doing one kind of work? → skill
- Could it read this off the code? → delete it
Applied to the stock AGENTS.md, five skills fall out cleanly:
| Skill | What moves into it |
|---|---|
elixir-gotchas |
language traps, Mix workflow, ExUnit mechanics |
phoenix-foundations |
Ecto, HEEx syntax, forms, router scoping |
phoenix-liveview |
streams, colocated JS hooks, push_event, LiveView tests |
liveview-interactions |
the client-vs-server decision for phx-click |
ui-and-assets |
layouts, components, Tailwind v4, bundling, design |
That takes AGENTS.md from 448 lines to about 60: stack, commands, the verification loop, commit conventions, product scope, and a flat list of the skills.
---
name: phoenix-liveview
description: LiveView mechanics — streams, colocated JS hooks, push_event, and LiveView tests. Use when writing or editing a LiveView module, working with streams, wiring up phx-hook, or writing Phoenix.LiveViewTest assertions. Triggers on LiveView, stream, stream_insert, phx-update, phx-hook, ColocatedHook, push_event, render_submit, element/2, has_element?, LazyHTML.
when_to_use: Writing or editing a LiveView, a LiveComponent, or a LiveView test.
paths: lib/my_app_web/live/**/*.ex, lib/my_app_web/live/**/*.heex
---description is the only field matched against the task, so write it for retrieval, not for humans. Name the symbols, function names, and template attributes that appear in the work — stream_insert, phx-update, to_form. A description that reads well and lists nothing concrete will not load when you need it.
Sections don't vanish from AGENTS.md — they shrink to the always-true rule plus a pointer:
Wrap public, crawler-hit, user-invariant endpoints in
Cache.fetch/3— at the call site. Thecachingskill has the full pattern, TTL conventions, and theasync: falsetest setup.
The rule stays visible on every task; the detail loads on demand.
On a new app, the five above are the whole list. Skills like brand, auth-and-scope, or profile-privacy encode product decisions — writing them before the product exists means putting invented rules in front of every future agent, which is worse than having no skill at all. Add them as the decisions get made.
Not every agent harness reads .claude/. Expose the same tree at .agents/skills:
mkdir -p .agents && ln -s ../.claude/skills .agents/skillsGit stores this as mode 120000, so it clones as a symlink and the two trees can't diverge.
Can't diverge by default. The failure mode is someone replacing the symlink with real copies — a sync tool, a Windows checkout, an over-helpful script — after which the mirror rots silently and half your agents read stale rules. So verify it in precommit. templates/skills.check.ex goes in lib/mix/tasks/; it resolves both trees, compares file contents, and fails with the exact drifted filenames.
Test the guard before trusting it. Replace the symlink with a copied tree, edit one file, and confirm mix skills.check fails and names that file. A guard nobody has seen fail is not a guard.
Claude Code's desktop app gives each session its own git worktree — a fresh checkout on its own branch, running in parallel with everything else. The catch is that git worktree add gives you only the files in the index: no deps, no database, no local secrets, and no free port. Four files close that gap so a new session is usable in seconds instead of after five minutes of hand-fixing.
.claude/settings.json registers a SessionStart hook. matcher: "startup" matters — SessionStart also fires on resume and clear, and you don't want mix setup running every time you reopen a tab.
.claude/hooks/session-start.sh fetches origin/main, fast-forwards or rebases depending on branch state, then runs mix setup. Every session starts on current code with deps installed and migrations applied. chmod +x it, or the hook silently does nothing — this is the single most common way this setup fails.
.claude/launch.json declares the dev server with autoPort: true. Without it, your second worktree's server dies on EADDRINUSE the moment you try to verify anything, because the first is already holding 4000.
.gitignore gets /.claude/worktrees/. The desktop app creates a full checkout per worktree under that directory, so committing them would be enormous and meaningless. Ignore that directory only — never .claude/ wholesale, since the settings, hooks, and skills are exactly what you want every developer to get.
autoPort doesn't change what your server listens on by itself. The desktop app finds a free port and passes it as the PORT environment variable; your framework has to read it. Phoenix 1.8 already does, with no port: line in config/dev.exs at all — verified against a stock mix phx.new app, where PORT=9999 mix phx.server serves on 9999.
Adding port: String.to_integer(System.get_env("PORT", "4000")) to the endpoint's http: options is harmless and self-documenting if you like it explicit. Either way, check your own version rather than taking this on faith:
PORT=9999 mix phx.server # should report http://localhost:9999If it comes up on 4000, fix that before anything else — the whole parallel-session story depends on it.
A fresh git worktree contains only tracked files, so anything gitignored is missing and the server won't boot. Give secrets a home before the first integration needs one:
config/dev.exs, at the end:
# Import development secrets if they exist. See config/dev.secret.example.exs
# for the expected shape. Copy it to config/dev.secret.exs and fill in values.
if File.exists?("config/dev.secret.exs") do
import_config "dev.secret.exs"
end.gitignore:
/config/*.secret.exs
That pattern matches dev.secret.exs but not dev.secret.example.exs, so the committed template survives. Verify rather than assume — a pattern that swallows your template makes it uncommittable, and you won't notice until a teammate clones:
git check-ignore -v config/dev.secret.exs # matches
git check-ignore -v config/dev.secret.example.exs # no matchCommit config/dev.secret.example.exs with commented entries per service, and add to it whenever you wire up a new one. Then .worktreeinclude carries the real file into new worktrees:
# Ignored local configuration that worktree tools should copy into each worktree.
config/dev.secret.exs
Verify the conditional import both ways. Put a probe value in config/dev.secret.exs, read it back with mix run -e 'IO.inspect(Application.get_env(:my_app, :probe))', delete the file, and confirm the app still boots. An import that silently never fires looks identical to having no secrets.
-
.tool-versionspins Erlang and Elixir;mix.exsmatches;_buildwiped after an OTP switch -
mix precommitruns compile, skills.check, deps.unlock, format, gettext, credo, sobelow, test — and exits 0 -
.credo.exsand.sobelow-confexist, with written reasons for every ignore -
AGENTS.mdis under ~100 lines and lists its skills -
.claude/skills/*/SKILL.mdhave trigger-loaded descriptions naming real symbols -
.agents/skillsis a symlink;mix skills.checkhas been seen to fail on deliberate drift -
.claude/settings.json,hooks/session-start.sh(executable),launch.jsonpresent -
/.claude/worktrees/ignored,.claude/itself committed -
PORT=9999 mix phx.serverserves on 9999 - Secret template committed, real secret ignored, conditional import verified both ways
-
.worktreeincludelists every untracked file the dev server needs
MIT — copy, adapt, share. Pull requests welcome, especially version-specific gotchas as Phoenix and Elixir move.