PhoenixDevToolbar

CI

A dev-only, BEAM-native debug toolbar for Phoenix LiveView applications, in the spirit of django-debug-toolbar: Ecto queries, LiveView assigns and lifecycle timings, process diagnostics and a plugin API, shown as an overlay on the page you are working on.

Status: early development. 0.0.x releases may break between versions. See the roadmap.

Disclaimer: this project is currently fully AI-coded and has not been reviewed by a human yet. Use it at your own risk.

What you get

Requirements

Installation

# mix.exs
{:phoenix_dev_toolbar, "~> 0.0.2", only: :dev}

# config/dev.exs
config :phoenix_dev_toolbar, repos: [MyApp.Repo]  # optional: defaults to your apps' :ecto_repos
config :my_app, MyApp.Repo, stacktrace: true  # optional: shows where each query comes from

# lib/my_app_web/endpoint.ex, next to Phoenix.LiveReloader
if code_reloading? do
  plug PhoenixDevToolbar.Plug
end

That's all: no router, layout or app.js changes. Installing with a coding agent? Point it at the agent setup guide: the toolbar, its MCP server and its agent skill, with a check for each step. See ADR 01 for why it never reaches production.

Configuration

Everything is optional and goes in config/dev.exs:

config :phoenix_dev_toolbar,
  # Panels: the built-ins plus your own (guides/writing-a-plugin.md)
  plugins: [:defaults, {MyApp.Toolbar.Jobs, queues: [:default]}],
  # The Ecto tab flags an N+1 at 5 repeats in one callback: change it, or false
  # plugins: [:defaults, {PhoenixDevToolbar.Plugins.Ecto, n_plus_one: 10}],
  # The Process tab reads the page's LiveView every second (60 points kept) and
  # its links and monitors every 5 seconds, only while the tab is open:
  # plugins: [:defaults, {PhoenixDevToolbar.Plugins.Process, sample_ms: 1000, samples: 60, relations_ms: 5000}],
  # The System tab inspects any process (a pid, a registered name, a link from the
  # Process tab), with the same options:
  # plugins: [:defaults, {PhoenixDevToolbar.Plugins.System, sample_ms: 1000}],
  # Opt-in: connected nodes (Cluster) and read queries or code on the node (Eval,
  # read only, and code only for this machine's browser with allow: :eval unless clients: :any,
  # see SECURITY.md):
  # plugins: [:defaults, PhoenixDevToolbar.Plugins.Cluster, {PhoenixDevToolbar.Plugins.Eval, allow: :eval}],
  # Kill, suspend, message peek, state read and trace are off (false, the default).
  # :local allows them for a browser on this machine (loopback or the machine's own
  # LAN address, no proxy); :any for Docker, the LAN or a proxy or tunnel (anyone
  # reaching the server can use them)
  # actions: :local,
  # The panel answers IP addresses, localhost, and your endpoint's url host; list
  # other names you use for the dev server (a DNS rebinding guard)
  # allowed_hosts: ["myapp.test"],
  # Behind a proxy or tunnel that rewrites the Host header, the page's origins
  # (scheme, host and port, matched exactly) the panel socket accepts
  # allowed_origins: ["https://abc.tunnel.example"],
  # The MCP endpoint for coding agents at <path>/mcp, for this machine only;
  # false turns it off, clients: adds IPs or CIDR ranges, or :any
  # mcp: [clients: ["192.168.1.0/24"]],
  # The toolbar makes room so the page scrolls clear of it; false lets it overlap
  # (the dock menu's toggle overrides this per browser)
  reserve_space: true,
  # Values of keys containing these words are shown as [REDACTED]; added to the
  # defaults (password, token, secret), which always apply
  redact: ["api_key"],
  # Memory bounds (ADR 03), these are the defaults. A session is one request
  # or one LiveView process: a LiveView page has two.
  limits: [
    max_events: 500,           # per plugin and session, oldest trimmed first
    max_session_events: 2000,  # per session, all plugins together
    max_sessions: 50,          # sessions kept, oldest closed ones evicted first
    events_per_second: 200,    # per session, the rest are counted as dropped
    # ttl_ms: 600_000,         # a session is dropped this long after its last event
    # sweep_ms: 30_000,        # how often expired sessions are looked for
    # down_grace_ms: 60_000,   # a LiveView that went down keeps its events this long
  ]

Each built-in plugin documents its options in its module docs: PhoenixDevToolbar.Plugins.Ecto, .Process, .System, .Cluster and .Eval.

Invalid limits values are logged at boot and keep their default.

When it is on

The toolbar enables itself only when the app runs under Mix with MIX_ENV=dev. Any other env (a release, prod, test, a custom one like local or staging) leaves it off unless you set config :phoenix_dev_toolbar, enabled: true. For a custom Mix env the dependency is not compiled there when declared only: :dev, so list that env too (only: [:dev, :local]) AND set enabled: true.

Turning it off for one run

Keep the dependency and the plug, and drive enabled from an environment variable:

# config/dev.exs
config :phoenix_dev_toolbar, enabled: System.get_env("DEV_TOOLBAR") != "0"

Then DEV_TOOLBAR=0 mix phx.server boots without the toolbar: no telemetry handlers, no supervised processes, and the plug passes requests through untouched. The value is read at boot, so restart to switch it.

From IEx or an agent

PhoenixDevToolbar.Report reads what the toolbar captured without a browser, from IEx in your dev server or from an agent that evaluates code in the running app (such as Tidewave):

alias PhoenixDevToolbar.Report

Report.latest(path: "/posts") |> Report.summary()   # queries, N+1, slow callbacks, errors
Report.sessions(path: ~r{^/posts}, limit: 5)
Report.latest(path: "/posts") |> Report.n_plus_one()

It sees what the panel sees, for as long as the toolbar keeps it (10 minutes by default). A separate mix run starts another VM with nothing captured.

Agents that drive a browser can open the panel in a tab of its own: /__pdt/panel?path=/posts shows the newest /posts page, /__pdt/panel the newest page. Its rows carry data-pdt-* attributes with raw values (microseconds, counts, sids), listed in the PhoenixDevToolbar.Plug docs.

MCP server

The toolbar is also an MCP server, at http://localhost:4000/__pdt/mcp while your dev server runs. Its read-only tools (list_pages, page_summary, page_queries, page_callbacks, page_n_plus_one, page_errors) return the same data as Report. It is on whenever the toolbar is, answers only clients on this machine (no proxy), and config :phoenix_dev_toolbar, mcp: false turns it off.

Check it with a ping, which answers a JSON result:

curl -s -X POST -H 'content-type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"ping"}' http://localhost:4000/__pdt/mcp

The tools answer "No page captured" until the page was loaded (browser, or curl with Accept: text/html) in the last 10 minutes.

Point your agent at it (change the port, or /__pdt if you set the plug's :path):

# Claude Code: writes .mcp.json, shared with the project
claude mcp add --transport http --scope project phoenix-dev-toolbar http://localhost:4000/__pdt/mcp

# Codex (recent releases): writes ~/.codex/config.toml
codex mcp add phoenix-dev-toolbar --url http://localhost:4000/__pdt/mcp

Or by hand:

// .mcp.json (Claude Code and other clients that read it)
{"mcpServers": {"phoenix-dev-toolbar": {"type": "http", "url": "http://localhost:4000/__pdt/mcp"}}}

// .cursor/mcp.json (Cursor)
{"mcpServers": {"phoenix-dev-toolbar": {"url": "http://localhost:4000/__pdt/mcp"}}}

// .vscode/mcp.json (VS Code)
{"servers": {"phoenix-dev-toolbar": {"type": "http", "url": "http://localhost:4000/__pdt/mcp"}}}
# ~/.codex/config.toml, or .codex/config.toml in a trusted project (Codex)
[mcp_servers.phoenix-dev-toolbar]
url = "http://localhost:4000/__pdt/mcp"

Claude Code asks once to approve a server from the project's .mcp.json; /mcp shows whether it is connected. Older Codex releases also need experimental_use_rmcp_client = true for HTTP servers. Start the dev server before the agent, or reconnect the server from the agent once it runs.

If the dev server listens on a LAN address (so phones can reach it), use that address in the URL (http://192.168.1.20:4000/__pdt/mcp): the agent on the same machine is still accepted. Agents elsewhere (a container, a VM, another device) need their addresses allowed:

# config/dev.exs
config :phoenix_dev_toolbar, mcp: [clients: ["192.168.1.0/24", "172.17.0.0/16"]]

clients: :any accepts everyone who reaches the server, proxies included: anyone on the network can then read what the toolbar captured. Anything on this machine that relays to the dev server (an SSH forward, socat, a local proxy without X-Forwarded-* headers, a VM or container with user-mode NAT) counts as this machine, as it already does for loopback.

The package ships usage rules and a skill that teaches coding agents this check-before-and-after routine. With usage_rules in your project, a skills-only config:

# mix.exs, project/0
usage_rules: [skills: [package_skills: [:phoenix_dev_toolbar]]]

then mix usage_rules.sync. Adding file: and usage_rules: [:phoenix_dev_toolbar] also syncs the rules, but rewrites the file's whole usage-rules block and drops the sections you do not list (phx.new puts Phoenix's there). The agent setup guide covers both, and installing without usage_rules.

How it compares

Troubleshooting

The toolbar does not appear.

A strict Content-Security-Policy hides it. Give the plug your CSP nonce through conn.assigns; the injected script and the toolbar frame style both use it:

conn = Plug.Conn.assign(conn, :csp_nonce, nonce)      # in a plug before PhoenixDevToolbar.Plug
plug PhoenixDevToolbar.Plug, nonce_assign: :csp_nonce # the default key; change it if yours differs

A frame-src or child-src that excludes your own origin still blocks the panel frame.

The panel answers 403. The host name you use is not allowed (a DNS rebinding guard). The panel answers IP addresses, localhost and your endpoint's url host; add other names with allowed_hosts: ["myapp.test"].

The panel does not connect behind a proxy or tunnel. If the proxy rewrites the Host header, the browser's origin no longer matches what the server sees and the socket is refused (the log names both). List the page's origin, exactly as the browser shows it: allowed_origins: ["https://abc.tunnel.example"] (no paths or wildcards; an entry that cannot match is logged at boot and ignored). Remove it when the tunnel is gone: a free tunnel name may later belong to someone else. A proxy that keeps the Host header needs the name in allowed_hosts instead.

Docker or LAN access. Who gets what, from the most open to the most guarded:

"This machine" is one rule everywhere. Anything on the machine that relays to the server (an SSH forward, socat, a proxy without X-Forwarded-* headers, a VM or container with user-mode NAT) counts as this machine. A container calling host.docker.internal also needs that name in allowed_hosts:. :loopback is still accepted wherever :local is, and means the same.

The Ecto tab is empty. The toolbar watches the repos in your apps' :ecto_repos; if there are none it logs one warning. Set repos: [MyApp.Repo] in config/dev.exs.

Security

The toolbar is for development. It is off outside MIX_ENV=dev, adds nothing to a production build when installed with only: :dev, and the panel refuses other origins and unknown host names. Process actions and the Eval plugin are opt-in and limited to a browser on this machine by default. Report vulnerabilities privately: see SECURITY.md.

Contributing

The ADR index (adr/README.md) is the place to start. AGENTS.md is written for AI coding agents. Run mix precommit before pushing (its JS tests need Node 22 or later). mix precommit rebuilds the assets (the first run downloads the esbuild binary, so it needs network) and fails if priv/static differs from the committed bundles. CI also compiles fixtures/host_app in prod and dev, which mix precommit does not (run it there with mix compile --warnings-as-errors). CI also tests the minimum supported versions (Elixir 1.17, OTP 26, LiveView 1.1): agent-tools/ci-min.sh runs that row locally in Docker. Try your changes in a real browser with mix dev, a demo app at http://localhost:4000.

License

MIT, see LICENSE.