> For the complete documentation index, see [llms.txt](https://cookbook.geuer-pollmann.de/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://cookbook.geuer-pollmann.de/elixir-beam/phx-port-and-justfile-for-ai-agents.md).

# Stable Port Assignments for Any Dev Server (AI Agent-Friendly)

**TL;DR** — Give your AI coding agent (GitHub Copilot, Claude Code, OpenAI Codex, Gemini CLI) a `justfile` that launches *any* local development server — Phoenix, Node.js, Python, Go, Ruby, or anything else that listens on a port — with stable, collision-free port assignments using [`phx-port`](https://github.com/chgeuer/phx-port). The agent can start, stop, and open your app without guessing ports or stepping on other running projects. For applications with real TLS certificates, `phx-port` can also discover SNI routes, listen on port 443, and either relay opaque TLS or hand the original socket to a cooperating Phoenix/Bandit application.

> Despite the name, `phx-port` is **not** Phoenix-specific. It works with any project that needs a local port.

This article is self-contained: point your coding agent at it and say *"Adopt the pattern in `https://cookbook.geuer-pollmann.de/elixir-beam/phx-port-and-justfile-for-ai-agents.md` for my project."*

***

## Table of Contents

1. [The Problem](#the-problem)
2. [The Pattern](#the-pattern)
3. [Step 1: Install `phx-port`](#step-1-install-phx-port)
4. [Step 2: Add the `justfile`](#step-2-add-the-justfile)
5. [Step 3: Update `.gitignore`](#step-3-update-gitignore)
6. [Step 4: Add Project Instructions for Your Agent](#step-4-add-project-instructions-for-your-agent)
7. [Optional: Real TLS on Port 443](#optional-real-tls-on-port-443)
8. [Optional: Hand the Original Socket to Phoenix](#optional-hand-the-original-socket-to-phoenix)
9. [How It All Fits Together](#how-it-all-fits-together)
10. [Elixir / BEAM Bonus: Combining with BEAM Introspection](#elixir--beam-bonus-combining-with-beam-introspection)

***

## The Problem

When you work on multiple web projects, they tend to default to the same port — 4000 for Phoenix, 3000 for Node/Rails, 8000 for Django, 8080 for Go. You end up either:

* Killing the old server before starting the new one
* Manually remembering which port you assigned to which project
* Passing `PORT=4007` and hoping you haven't already used 4007 somewhere else

AI coding agents make this worse. When an agent needs to start your dev server to validate a change, it doesn't know which port to use. If another project is already running on that port, the server fails to bind and the agent wastes time debugging a port conflict instead of doing real work.

**What we want**: every project gets a stable, unique port — automatically — and the agent has a single command to start the server, open the browser, or stop the process.

## The Pattern

Two pieces work together:

1. [**`phx-port`**](https://github.com/chgeuer/phx-port) — a small Rust CLI that maintains a TOML registry (`~/.config/phx-ports.toml`) mapping project directories to port numbers. Each project gets a unique port, allocated once and reused forever. Despite its name, it is **framework-agnostic** — it works with Phoenix, Express, Django, Rails, Go, or any server that reads a `PORT` environment variable. Port 4000 is kept free for ad-hoc use.
2. **A `justfile`** — a [`just`](https://github.com/casey/just) command runner file in the project root that wires together `phx-port` and your project's start command. For Elixir/BEAM projects, it can additionally integrate distributed Erlang (`--sname` / `--cookie`) and the [BEAM introspection script](/elixir-beam/beam-introspection-for-ai-agents.md) (`scripts/dev_node.sh`).

The agent (or you) runs `just start` and gets a server on a known, stable port — every time, on every machine.

### How `phx-port` works

`phx-port` auto-detects behavior based on context:

| Context                                               | Behavior                                                                             |
| ----------------------------------------------------- | ------------------------------------------------------------------------------------ |
| **Piped** (e.g. `PORT=$(phx-port)`)                   | Prints just the port number. Auto-registers the current directory if not yet known.  |
| **Interactive** (run in a terminal with no arguments) | Shows help text. Never auto-registers accidentally.                                  |
| `phx-port list`                                       | Shows all registered projects as a directory tree with clickable URLs.               |
| `phx-port open`                                       | Opens the default browser at `http://localhost:<port>` for the current project.      |
| `phx-port register`                                   | Explicitly registers the current directory for a new port.                           |
| `phx-port register debug`                             | Registers a named port role (e.g., for a debug port, metrics endpoint, etc.).        |
| `phx-port discover`                                   | Opens a browser view of running projects and certificate-verified HTTPS sites.       |
| `phx-port proxy status`                               | Reports the running TLS router, routes, resource usage, relay, and handoff counters. |
| `phx-port proxy routes`                               | Shows active and conflicting certificate-verified SNI routes.                        |

Projects can have multiple named port roles:

```bash
# Main server port (works with any framework)
PORT=$(phx-port) npm start           # Node.js
PORT=$(phx-port) mix phx.server      # Phoenix
PORT=$(phx-port) python manage.py runserver 0.0.0.0:$PORT  # Django
PORT=$(phx-port) go run .            # Go (if your app reads $PORT)

# Main + named ports
PORT=$(phx-port) PORT_DEBUG=$(phx-port debug) mix phx.server
PORT=$(phx-port) HTTPS_PORT=$(phx-port https) mix phx.server
```

The registry looks like this:

```toml
[ports."/home/user/projects/my_app"]
main = 4001
https = 4004

[ports."/home/user/projects/api_gateway"]
main = 4002
debug = 4003
```

***

## Step 1: Install `phx-port`

```bash
cargo install --git https://github.com/chgeuer/phx-port
```

Or build from source:

```bash
git clone https://github.com/chgeuer/phx-port
cd phx-port
cargo build --release
cp target/release/phx-port ~/.local/bin/   # or anywhere on your PATH
```

Verify it works:

```bash
phx-port --version
```

***

## Step 2: Add the `justfile`

Create a `justfile` in your project root. This is the single entry point for starting, stopping, and managing the server. Below are examples for different stacks.

### Generic `justfile` (Node.js, Python, Go, etc.)

This works for any server that reads the `PORT` environment variable:

```just
# Start the server (visible output, logs to run.log)
start:
    #!/usr/bin/env bash
    export PORT="${PORT:-$(phx-port)}"
    echo "Starting on port $PORT..."
    exec your-start-command 2>&1 | tee run.log

# Start the server in background (logs to run.log only)
start-bg:
    #!/usr/bin/env bash
    export PORT="${PORT:-$(phx-port)}"
    your-start-command > run.log 2>&1 &
    echo "Started in background on port $PORT (PID $!). Logs in run.log"

# Open the app in a browser
open:
    phx-port open

# Show the assigned port
port:
    @phx-port
```

Replace `your-start-command` with what your project needs — `npm start`, `python manage.py runserver 0.0.0.0:$PORT`, `go run .`, `bundle exec rails server -p $PORT`, etc.

### Elixir / Phoenix `justfile`

For Phoenix or other BEAM projects, the `justfile` can additionally wire in distributed Erlang for live introspection:

```just
# Start the Phoenix server (visible output, logs to run.log)
start:
    #!/usr/bin/env bash
    SNAME="$(basename "$(pwd)")"
    export PORT="${PORT:-$(phx-port)}"
    export ELIXIR_ERL_OPTIONS="-sname $SNAME -setcookie devcookie"
    exec mix phx.server 2>&1 | tee run.log

# Start the Phoenix server in background (logs to run.log only)
start-bg:
    #!/usr/bin/env bash
    SNAME="$(basename "$(pwd)")"
    export PORT="${PORT:-$(phx-port)}"
    export ELIXIR_ERL_OPTIONS="-sname $SNAME -setcookie devcookie"
    exec mix phx.server > run.log 2>&1

# Open the app in a browser (starts the server if not running)
open:
    #!/usr/bin/env bash
    SNAME="$(basename "$(pwd)")"
    if ! scripts/dev_node.sh status > /dev/null 2>&1; then
        echo "Node $SNAME not running, starting in background..."
        export PORT="${PORT:-$(phx-port)}"
        export ELIXIR_ERL_OPTIONS="-sname $SNAME -setcookie devcookie"
        mix phx.server > run.log 2>&1 &
        scripts/dev_node.sh await
    fi
    phx-port open

# Stop the running BEAM node gracefully
stop:
    scripts/dev_node.sh rpc "System.stop()"

# Check if the BEAM node is running
status:
    scripts/dev_node.sh status

# Execute an expression on the running BEAM node
rpc EXPR:
    scripts/dev_node.sh rpc "{{EXPR}}"
```

### What each recipe does

| Recipe              | Description                                                                                                                                                                             |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `just start`        | Starts the server in the foreground. Output goes to the terminal **and** `run.log`.                                                                                                     |
| `just start-bg`     | Same, but runs silently in the background. All output goes to `run.log`.                                                                                                                |
| `just open`         | Opens the app in your browser. For the Elixir version, starts the server in the background first if needed.                                                                             |
| `just stop`         | *(Elixir)* Gracefully shuts down the running BEAM node via `System.stop()`.                                                                                                             |
| `just status`       | *(Elixir)* Checks whether the BEAM node is registered with `epmd`.                                                                                                                      |
| `just rpc '<expr>'` | *(Elixir)* Evaluates an Elixir expression on the running node (requires `scripts/dev_node.sh` from the [BEAM introspection pattern](/elixir-beam/beam-introspection-for-ai-agents.md)). |

### Key design decisions

* **`PORT` respects overrides** — `${PORT:-$(phx-port)}` means you can still do `PORT=9999 just start` if needed, but the default is always the stable `phx-port` assignment.
* **`exec` replaces the shell** — the server process takes over the shell's PID, so signals (Ctrl+C) go directly to it.
* *(Elixir-specific)* **`--sname` is derived from the directory name** — no configuration needed. The project directory `my_app` becomes node `my_app@hostname`.
* *(Elixir-specific)* **`--cookie devcookie`** — a shared development cookie so `dev_node.sh` can connect for introspection.

***

## Step 3: Update `.gitignore`

Add runtime artifacts that shouldn't be committed:

```gitignore
# Runtime files
run.log
.dev_node.log
.dev_node.pid
```

***

## Step 4: Add Project Instructions for Your Agent

Tell your AI coding agent about the `justfile` so it knows how to start and manage the server. Add this to your project's agent instructions file.

### GitHub Copilot — `AGENTS.md` or `.github/copilot-instructions.md`

```markdown
### Server management

Use `just` recipes to manage the development server:

- `just start` — start the server (foreground, visible output)
- `just start-bg` — start the server in the background
- `just open` — open the app in the browser
- `just port` — show the assigned port number

The server uses `phx-port` for stable port assignment. Never hardcode port numbers.
To make HTTP requests against the running server, use `http://localhost:$(phx-port)`.
```

> For Elixir/BEAM projects, add `just stop`, `just status`, and `just rpc '<expression>'` to the list above — see the Elixir `justfile` variant.

### Claude Code — `CLAUDE.md`

```markdown
## Server management

Use `just` recipes to manage the development server:

- `just start` — start the server (foreground, visible output)
- `just start-bg` — start the server in the background
- `just open` — open the app in the browser
- `just port` — show the assigned port number

The server uses `phx-port` for stable port assignment. Never hardcode port numbers.
To make HTTP requests against the running server, use `http://localhost:$(phx-port)`.
```

### OpenAI Codex / Gemini CLI — `AGENTS.md`

Same content as the Copilot section above. Both tools read `AGENTS.md` at the project root.

***

## Optional: Real TLS on Port 443

Stable ports solve collisions, but a certificate-backed development site is still awkward at `https://www.contoso.com:4004/`. The `phx-port` daemon can own the standard TLS port and discover which live application owns each hostname:

```
www.contoso.com  -> /home/user/projects/contoso  (https) :4004
www.fabrikam.com -> /home/user/projects/fabrikam (https) :4007
```

The applications, not `phx-port`, own their certificates and private keys. Each application runs an ordinary HTTPS listener on its assigned `https` role:

```just
start:
    #!/usr/bin/env bash
    set -euo pipefail
    SNAME="$(basename "$(pwd)")"
    export PORT="${PORT:-$(phx-port)}"
    export HTTPS_PORT="${HTTPS_PORT:-$(phx-port https)}"
    exec elixir --sname "$SNAME" --cookie devcookie -S mix phx.server 2>&1 | tee run.log

port:
    #!/usr/bin/env bash
    echo "http=$(phx-port)"
    echo "https=$(phx-port https)"
```

Configure the Phoenix endpoint's HTTPS listener from `HTTPS_PORT` and its normal certificate configuration. DNS must point each hostname at the development machine; DNS-01 ACME works well because certificate issuance does not require an existing public HTTP listener.

Run the router in the foreground:

```bash
phx-port daemon --listen 0.0.0.0:443 --listen '[::]:443'
```

Or install its systemd user service:

```bash
phx-port proxy install-service
phx-port proxy status
phx-port proxy routes
```

The foreground process or user service must be allowed to bind privileged port 443 on the host. Configure that permission using the operating system's normal low-port policy; do not run the Phoenix applications as root.

For an explicit `https` role, the daemon first inspects the backend's default certificate and validates each concrete DNS SAN with a hostname-specific TLS probe. If an incoming SNI hostname has not been seen before, it performs a bounded scan of live TLS workloads. A route is accepted only when exactly one backend presents a system-trusted certificate valid for that hostname.

The original ClientHello then reaches the selected application unchanged. `phx-port` does not terminate TLS, read private keys, or implement an HTTP proxy. An unmodified TLS workload receives an opaque layer-4 relay.

`phx-port discover` includes the assigned localhost endpoints and public `https://hostname/` links for certificate-verified routes whose applications are currently running.

## Optional: Hand the Original Socket to Phoenix

On Linux, a Phoenix/Bandit application can opt into a faster path using the [`PhxPortHandoff`](https://github.com/chgeuer/phx-port/tree/master/integrations/elixir/phx_port_handoff) package. Instead of opening a second backend TCP connection and relaying bytes, `phx-port` passes the accepted port-443 descriptor over a private Unix `SOCK_SEQPACKET` socket with `SCM_RIGHTS`.

The daemon reads SNI with `MSG_PEEK`, so it does not consume the ClientHello. The receiving application adopts the descriptor with `:gen_tcp.fdopen/2`, performs its normal server-side TLS handshake, and enters the regular Thousand Island and Bandit connection pipeline. The application sees the original client address and talks directly to the browser after handoff.

Add the integration package until it is published:

```elixir
{:phx_port_handoff,
 path: "/path/to/phx-port/integrations/elixir/phx_port_handoff",
 only: :dev}
```

Start a handoff-only Bandit child immediately before the ordinary endpoint, using the same HTTPS options:

```elixir
https = Application.fetch_env!(:my_app, MyAppWeb.Endpoint)[:https]

children = [
  PhxPortHandoff.bandit_child_spec(MyAppWeb.Endpoint, File.cwd!(), "https", https),
  MyAppWeb.Endpoint
]
```

Keep the ordinary HTTPS listener. The daemon uses it for certificate discovery, health checks, and direct diagnostics; the handoff-only listener accepts original port-443 sockets on its private Unix endpoint. If no compatible receiver is available, `phx-port` safely falls back to opaque TLS relay before descriptor delivery.

Current handoff requirements are Linux, Erlang/OTP 29 or later, Bandit, Thousand Island, and Rustler 0.36.2. Rustler 0.38 is intentionally excluded until its imported-descriptor ownership conflict is understood.

For the implementation and ownership details, see [Proxying Without the Proxy](https://github.com/chgeuer/phx-port/blob/master/docs/proxying-without-the-proxy.md) and the [connected socket forwarding design](https://github.com/chgeuer/phx-port/blob/master/docs/socket-forwarding-design.md).

***

## How It All Fits Together

Here's a typical workflow, whether it's you or an AI agent:

```bash
# First time — phx-port auto-registers the project
~/projects/my_app $ just start
Registered /home/user/projects/my_app → port 4001    # ← stderr, first time only
Starting on port 4001...
Listening on http://localhost:4001

# Second time — port is already known, instant
~/projects/my_app $ just start
Starting on port 4001...
Listening on http://localhost:4001

# Meanwhile, in another project (Node.js, Python, anything) — no conflict
~/projects/api $ just start
Registered /home/user/projects/api → port 4002
Starting on port 4002...
Server running at http://localhost:4002

# Check what's registered across all your projects
$ phx-port list
/home/user/projects
├── my_app ...... http://localhost:4001
└── api ......... http://localhost:4002
```

An AI agent working on `my_app` simply runs `just start-bg`, waits for the server, and can then `curl http://localhost:$(phx-port)` to validate its changes — without worrying about port conflicts with the `api` project running in the background.

***

## Elixir / BEAM Bonus: Combining with BEAM Introspection

This pattern is designed to work together with the [BEAM Live Introspection](/elixir-beam/beam-introspection-for-ai-agents.md) pattern. The `justfile` recipes (`stop`, `status`, `rpc`) delegate to `scripts/dev_node.sh`, which provides full runtime introspection capabilities.

To set up both patterns together:

1. Follow this article to add `phx-port` and the `justfile`
2. Follow the [BEAM introspection article](/elixir-beam/beam-introspection-for-ai-agents.md) to add `scripts/dev_node.sh` and the introspection skill

The `justfile` becomes the high-level interface ("start my server"), while `dev_node.sh` provides the low-level distributed Erlang plumbing ("connect to the running node and evaluate this expression").

```
justfile                    ← Human / agent entry point
├── start / start-bg        ← Uses phx-port for port, --sname for node naming
├── open                    ← Auto-starts + opens browser via phx-port
├── stop                    ← Delegates to dev_node.sh rpc "System.stop()"
├── status                  ← Delegates to dev_node.sh status
└── rpc                     ← Delegates to dev_node.sh rpc

scripts/dev_node.sh         ← BEAM introspection engine
├── start / stop / status   ← Node lifecycle
├── await                   ← Wait for node to be connectable
├── rpc                     ← Execute expression on live node
└── eval_file               ← Evaluate .exs file on live node
```
