Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Making a new Phoenix app AI-native

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/.


1. Pin the toolchain

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.


2. Make mix precommit mean something

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-datedefault.pot doesn't exist mix gettext.extract --merge once, then commit the generated .pot / .po files
credo --strictPhoenix.LiveView.JS not alphabetically ordered in <app>_web.ex Swap the two alias lines. Phoenix's own generated code trips its own check
sobelowConfig.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.


3. Split AGENTS.md into skills

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:

  1. Does an agent need this on every task? → AGENTS.md
  2. Only when doing one kind of work? → skill
  3. 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.

Skill frontmatter

---
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.

Leave a stub, not a hole

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/3at the call site. The caching skill has the full pattern, TTL conventions, and the async: false test setup.

The rule stays visible on every task; the detail loads on demand.

Don't write skills for decisions you haven't made

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.


4. Mirror the skills, and guard the mirror

Not every agent harness reads .claude/. Expose the same tree at .agents/skills:

mkdir -p .agents && ln -s ../.claude/skills .agents/skills

Git 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.


5. Claude Code harness config

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.

On autoPort and PORT

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:9999

If it comes up on 4000, fix that before anything else — the whole parallel-session story depends on it.


6. Local secrets and worktrees

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 match

Commit 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.


Checklist

  • .tool-versions pins Erlang and Elixir; mix.exs matches; _build wiped after an OTP switch
  • mix precommit runs compile, skills.check, deps.unlock, format, gettext, credo, sobelow, test — and exits 0
  • .credo.exs and .sobelow-conf exist, with written reasons for every ignore
  • AGENTS.md is under ~100 lines and lists its skills
  • .claude/skills/*/SKILL.md have trigger-loaded descriptions naming real symbols
  • .agents/skills is a symlink; mix skills.check has been seen to fail on deliberate drift
  • .claude/settings.json, hooks/session-start.sh (executable), launch.json present
  • /.claude/worktrees/ ignored, .claude/ itself committed
  • PORT=9999 mix phx.server serves on 9999
  • Secret template committed, real secret ignored, conditional import verified both ways
  • .worktreeinclude lists every untracked file the dev server needs

License

MIT — copy, adapt, share. Pull requests welcome, especially version-specific gotchas as Phoenix and Elixir move.

About

Six steps to make a new Phoenix app work well with AI coding agents: pin the toolchain, turn mix precommit into a real gate, split the generated AGENTS.md into trigger-loaded skills, and keep it all from drifting. Includes a paste-ready setup prompt.

Topics

Resources

Stars

15 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages