Web based, self-hosted LLM-powered coding agent.
Forking encouraged, make your own personal IDE that matches your workflow.
Self hosted means you can poke it when it breaks. All data is stored in a single local sqlite db.
Server/client architecture from day 1 means you can run the API half on a remote coding VM and access from anywhere!
Supported Options:
- API keys ->
ANTHROPIC_API_KEYand/orOPENAI_API_KEY - Paid ChatGPT / Codex -> in-app login!
- "We want people to be able to use Codex, and their ChatGPT subscription, wherever they like!" - from OpenAI themselves
- Browser and device code supported.
- Custom provider-compatible endpoint ->
ANTHROPIC_BASE_URL/OPENAI_BASE_URL
See the env var section for more details (or point your preferred coding agent at the repo)
# Start everything (backend build + frontend dev server)
./dev.py up
# Other lifecycle commands
./dev.py down # stop services
./dev.py restart # restart services
./dev.py status # show running state
./dev.py check # pre-commit checks (fmt, clippy, tests)
# Optional: run the dev backend over HTTPS with h2 ALPN enabled
./dev.py up --https# Runs via uv — no manual dependency install needed
./phoenix-client.py -d /tmp "Create hello.txt with 'Hello World'"
./phoenix-client.py -c <conversation-slug> "Now modify it"Rust backend serves the API and, in production, embeds the React frontend via rust-embed.
SQLite persists conversations and messages. A bedrock state machine drives the conversation
lifecycle (Idle → Processing → ToolExecuting → …). Tools are modular and LLM-invokable.
Multi-provider LLM support: the Anthropic and OpenAI APIs, a ChatGPT/Codex bridge, or provider-compatible base URLs.
State is visible, not inferred. The UI reflects exact system state — which tool is running, how many are queued, what retry attempt you're on, whether your message was sent or is still in the local queue. Nothing is hidden behind a spinner.
Deterministic core. The conversation lifecycle is a pure state machine (Elm architecture): same inputs always produce the same outputs. All I/O is isolated in effect executors. State transitions are property-tested.
Recoverable by default. State is persisted to SQLite on every transition. SSE reconnection replays missed events by sequence ID. Drafts survive tab close. The server restarts to a known-good state.
Subtle, not minimal. The UI is information-dense — it communicates clearly without wasting visual elements. Status is shown inline with symbols and color, not buried in separate screens or modals. Progressive disclosure: essentials visible by default, details on demand.
| Tool | Description | Spec |
|---|---|---|
| bash | Shell command execution with wait windows, truncation, and handle-based background observation | spec |
| patch | Structured file editing — create, modify, delete with fuzzy matching | spec |
| read_file | Read a file, or a line range of one (any path the server process can access) | — |
| search | grep + glob over a directory tree, ripgrep-style (any path the server process can access) | — |
| keyword_search | Semantic code search using LLM-filtered results | spec |
| think | Reasoning scratchpad with zero side effects | spec |
browser (browser_*) |
Headless browser — navigate, eval JS, screenshot, click/type/keypress, resize, console logs | spec |
| read_image | Read and encode image files for vision models | — |
| tmux | Drive a persistent tmux session for long-lived / interactive processes | spec |
| terminal_last_command / terminal_command_history | Inspect the in-app terminal's last command and history | spec |
| spawn_agents | Parallel task delegation to child agents | spec |
| ask_user_question | Ask the user a structured multiple-choice question mid-run | spec |
| skill | Invoke a user-defined skill (SKILL.md instruction set) | spec |
| propose_task | Propose a task file (task-authoring conversations only) | — |
| MCP tools | Tools exposed by configured MCP servers, registered dynamically at startup | — |
Designed to run as a background service: a single static binary that serves the HTTP API with the React UI embedded.
./dev.py prod deploy # check/build local HEAD, then deploy transactionally
./dev.py prod deploy --release vX.Y.Z # deploy a checksummed published release
./dev.py prod deploy --release latest # resolve and deploy the latest stable release
./dev.py prod status # backend, runtime identity, and durable transaction
./dev.py prod stop # stop the backend-owned runtimeTLS is opt-in. The lowest-toil internal-DNS flow is a local Phoenix private CA that you trust once on your browser machine, then use to issue per-host leaf certificates. The CA private key stays on the machine where you issue certs; the remote host receives only its leaf cert and key.
# On the machine that owns the Phoenix CA, create/show the CA.
./dev.py tls ca
# Trust this CA cert once on the browser machine:
# ~/.phoenix-ide/tls/phoenix-local-ca.pem
# Issue a bundle for the hostname you will open in the browser.
./dev.py tls issue phoenix-host.internal
# Copy only the bundle to the remote host.
scp ~/.phoenix-ide/tls-bundles/phoenix-host.internal.tar.gz ssh-host:~/
# On the remote host, from its phoenix-ide repo checkout:
./dev.py tls install ~/phoenix-host.internal.tar.gz
./dev.py prod deployAfter install, ./dev.py prod deploy reads .phoenix-ide.env and serves
https://phoenix-host.internal:8031. For local development, ./dev.py up --https uses the same default CA directory, keeps the dev UI on the Vite URL,
and proxies API requests to Phoenix over HTTPS.
Merge a version-bump PR to main. The release workflow tags that main commit and publishes checksummed native assets for:
aarch64-apple-darwinx86_64-apple-darwinaarch64-unknown-linux-muslx86_64-unknown-linux-musl
Each stable asset URL follows:
https://github.com/scottopell/phoenix-ide/releases/latest/download/phoenix_ide-<target>
Everything Phoenix reads. The server reads its config from the environment at
startup; ./dev.py and the production deploy paths populate most of these for
you (prod reads .phoenix-ide.env from the repo root of the checkout you deploy from).
| Variable | Purpose | Default |
|---|---|---|
PHOENIX_PORT |
HTTP(S) listen port | 8000 |
PHOENIX_DB_PATH |
SQLite database path. If the path contains prod, startup logs "production mode" |
$HOME/.phoenix-ide/phoenix.db |
PHOENIX_PASSWORD |
Optional auth password (REQ-AUTH-001). Empty/unset disables password auth | — (disabled) |
RUST_LOG |
tracing filter (info, debug, phoenix_ide=debug, …) |
env-default filter |
HOME / USERPROFILE |
Home dir — used for the default DB path, built-in-skill extraction, working-dir resolution, tmux sockets | OS home; /tmp fallback |
| Variable | Purpose | Default |
|---|---|---|
ANTHROPIC_API_KEY |
Direct Anthropic API key | — |
ANTHROPIC_BASE_URL |
Override the Anthropic API base URL | — |
OPENAI_API_KEY |
Direct OpenAI API key | — |
OPENAI_BASE_URL |
Legacy OpenAI endpoint override; routed only when its path matches the selected OpenAI wire format | — |
OPENAI_RESPONSES_BASE_URL |
Exact endpoint for OpenAI Responses-compatible models | — |
OPENAI_CHAT_COMPLETIONS_BASE_URL |
Exact endpoint for OpenAI Chat Completions-compatible models | — |
DEFAULT_MODEL |
Preferred default model ID (used only if it actually registers) | first registered model |
PHOENIX_LLM_MODELS |
Inline JSON array of additional model specs to add to the built-in registry | — |
LLM_API_KEY_HELPER |
Command that prints a fresh API key/token on stdout (e.g. claude OAuth helper) |
— |
LLM_API_KEY_HELPER_TTL_MS |
How long a helper-produced credential is cached | 7200000 (2 h) |
LLM_CUSTOM_HEADERS |
Extra request headers — newline-separated Key: value (literal \n accepted); a provider header is auto-injected |
— |
LLM_REQUEST_TAGS |
Comma-separated key=value request tags |
— |
LLM_AUTH_HEADER |
bearer → send the key as Authorization: Bearer …; anything else → provider's native API-key header |
api-key style |
PHOENIX_ENABLE_MOCK_MODEL |
1 → register the deterministic mock provider (testing only) |
off |
If none of ANTHROPIC_API_KEY, OPENAI_API_KEY, LLM_API_KEY_HELPER,
or a ChatGPT/Codex credential is present, the server starts with no models and
logs a warning.
PHOENIX_LLM_MODELS is additive. It does not override built-in model IDs; a
configured duplicate ID is ignored and Phoenix logs a warning while keeping the
built-in definition. The value is parsed at startup, and invalid JSON or invalid
fields are logged and ignored without removing built-in models. Each configured
model object has this shape:
[
{
"id": "provider-compatible/model-id",
"api_name": "optional-wire-model-name",
"backend": "anthropic",
"family": "anthropic",
"description": "Human-readable picker text",
"context_window": 200000,
"max_output_tokens": 16384,
"recommended": false,
"supports_tool_search": false
}
]api_name may be omitted; when absent Phoenix sends id as the wire model
name. backend selects both route/auth family and wire protocol. Supported
values are anthropic (Anthropic Messages-compatible), openai_responses
(OpenAI Responses-compatible), and openai_chat_completions (OpenAI Chat
Completions-compatible). family controls the user-facing provider label and
defaults from backend; set it when model ownership differs from wire format.
max_output_tokens is optional and defaults to a context-safe cap.
Example Anthropic-compatible provider POC:
ANTHROPIC_API_KEY=provider-api-key
ANTHROPIC_BASE_URL=https://provider.example/v1/messages
LLM_CUSTOM_HEADERS=source: my-provider-poc
DEFAULT_MODEL=example/provider-model
PHOENIX_LLM_MODELS=[{"id":"example/provider-model","backend":"anthropic","description":"Example Anthropic-compatible POC","context_window":200000,"recommended":false,"supports_tool_search":false}]Example Chat Completions-compatible provider POC:
OPENAI_API_KEY=provider-api-key
OPENAI_CHAT_COMPLETIONS_BASE_URL=https://provider.example/v1/chat/completions
DEFAULT_MODEL=example/chat-model
PHOENIX_LLM_MODELS=[{"id":"example/chat-model","api_name":"provider/example/chat-model","backend":"openai_chat_completions","family":"Google","description":"Example Chat Completions-compatible POC","context_window":128000,"max_output_tokens":8192,"recommended":false,"supports_tool_search":false}]| Variable | Purpose | Default |
|---|---|---|
PHOENIX_TLS |
HTTPS mode: auto/on/true/1, manual, or off/none/false/0 |
off |
PHOENIX_TLS_HOSTS |
Comma-separated extra DNS/IP SANs for PHOENIX_TLS=auto |
localhost,127.0.0.1,::1 |
PHOENIX_TLS_DIR |
Managed local-CA + auto-issued leaf certificate directory | parent of PHOENIX_DB_PATH + /tls |
PHOENIX_TLS_CERT_PATH |
Manual TLS certificate PEM path; together with the key path, enables manual TLS even if PHOENIX_TLS is unset |
— |
PHOENIX_TLS_KEY_PATH |
Manual TLS private-key PEM path; required alongside the cert path | — |
TLS is opt-in. PHOENIX_TLS=auto creates a private Phoenix CA in
PHOENIX_TLS_DIR if one is not already present, then rotates the server leaf
certificate on startup. PHOENIX_TLS=manual serves the cert/key paths exactly as
configured; this is what ./dev.py tls install writes for remote production
hosts. See TLS.md for the complete trust and deployment workflow.
| Variable | Purpose | Default |
|---|---|---|
PHOENIX_DATA_DIR |
Base dir for runtime data — currently the per-conversation tmux socket dir ($PHOENIX_DATA_DIR/tmux-sockets/) |
$HOME/.phoenix-ide |
PHOENIX_CHROME_EXECUTABLE |
Explicit Chrome/Chromium path for the browser tool; falls back to auto-detection |
auto-detect |
PHOENIX_PARENT_TOOL_CYCLE_CAP |
Max tool cycles a parent agent may run before yielding (non-negative int; 0 disables the cap) |
built-in default |
PATH |
Used to locate tmux, Chrome, and shell binaries; inherited into the in-app terminal |
inherited |
SHELL |
Shell the in-app terminal launches | /bin/bash |
The in-app terminal is given a deliberately minimal environment (it never
inherits the server's env, to avoid leaking secrets): TERM, COLORTERM,
HOME, USER/LOGNAME, SHELL, PATH, LANG, plus shell-integration hints
(TERM_PROGRAM=phoenix-ide, ITERM_SHELL_INTEGRATION_INSTALLED=Yes). USER
falls back to LOGNAME.
| Variable | Purpose |
|---|---|
LISTEN_FDS / LISTEN_PID |
systemd-style socket passing — the listening socket survives an in-place binary swap (./dev.py restart, prod deploy). Managed automatically; the binary clears them after adopting the fd. |
launchd Sockets / Listeners |
macOS launchd socket passing — ./dev.py prod deploy installs an IPv4/v6 dual-stack listener named Listeners, and the binary adopts it with launch_activate_socket. |
| Variable | Purpose |
|---|---|
PHOENIX_SKIP_BROWSER_TESTS |
1 → skip browser-tool tests (no Chrome available) |
PHOENIX_SKIP_NETWORK_TESTS |
1 → skip tests that need outbound network |
| Variable | Purpose |
|---|---|
PHOENIX_PUBLIC_URL |
Display URL shown in ./dev.py prod status / deploy output |
PHOENIX_VERSION |
Version string ./dev.py bakes into the systemd/prod env (the server reports its own CARGO_PKG_VERSION) |
VITE_API_PORT / VITE_API_SCHEME / VITE_API_PROXY_SECURE |
How the Vite dev server proxies /api to Phoenix |
GET /api/conversations- List all conversationsPOST /api/conversations/new- Create new conversationGET /api/conversations/:id- Get conversation detailsPOST /api/conversations/:id/messages- Send a messageGET /api/conversations/:id/stream- SSE stream for real-time updates
specs/— Per-tool and subsystem specs using the spEARS methodology- LAUNCHD.md — macOS launchd deployment and socket activation
- TLS.md — HTTPS, HTTP/2, private CA, and deployment workflow
- AGENTS.md — Agent architecture and conventions