HTTP API Reference¶
Base URL¶
By default, {port} is a dynamically assigned free localhost port chosen at startup — discover it with prox status (which reports the API address), or pin it by setting api.port in prox.yaml. The examples below assume api.port: 5555 is set in prox.yaml; prox itself does not pin a port by default, so substitute whatever port prox status reports unless you've explicitly set api.port.
Authentication¶
By default, authentication is enabled automatically unless prox binds to a localhost-only address (127.0.0.1, localhost, or ::1); binding to 0.0.0.0 or another non-localhost address turns auth on. When enabled, a bearer token is generated and stored in ~/.prox/token.
This can be overridden with the api.auth field in prox.yaml:
- Omitted (default): auto-determine based on
api.hostas described above. true: force authentication on, even when bound to localhost.false: force authentication off, even when bound to a non-localhost address. prox prints a startup warning in this case, since any network client can then control the supervisor.
Include the token in requests:
Error Format¶
All errors return JSON:
Error Codes¶
| Code | Description |
|---|---|
PROCESS_NOT_FOUND |
Process name does not exist |
PROCESS_ALREADY_RUNNING |
Process is already running |
PROCESS_NOT_RUNNING |
Process is not running |
INVALID_PATTERN |
Invalid regex pattern |
SHUTDOWN_IN_PROGRESS |
Supervisor is shutting down |
ENV_RELOAD_FAILED |
A process's env_file could not be re-read from disk on start |
PROCESS_GROUP_NOT_REAPED |
A process's group survived SIGKILL and could not be confirmed terminated |
CONFIG_RELOAD_FAILED |
prox.yaml could not be re-read/validated on an API-driven (re)start (missing/unreadable file, YAML syntax, or validation failure); the running process is left untouched |
PROCESS_NOT_IN_CONFIG |
The target process is no longer present in prox.yaml on a (re)start; run prox up to reconcile |
PROXY_NOT_ENABLED |
Proxy request inspection is unavailable because the proxy is disabled |
STREAMING_NOT_SUPPORTED |
The server cannot provide an SSE stream for this request |
REQUEST_NOT_FOUND |
Proxy request ID does not exist in the request buffer |
MISSING_REQUEST_ID |
Request detail endpoint was called without an ID |
CURSOR_GONE |
A before_id cursor's anchor record is unknown, evicted, or out of scope for the current filters; restart pagination without a cursor |
Endpoints¶
GET /health¶
Plain liveness check, served at the server root (not under /api/v1). No authentication required.
Response: 200 OK with plain-text body ok (not JSON).
GET /status¶
Supervisor status.
Response:
{
"status": "running",
"uptime_seconds": 7200,
"config_file": "/path/to/prox.yaml",
"api_version": "v1",
"proxy": {
"mode": "shared",
"daemon_reachable": true,
"daemon_version": "0.2.0",
"consecutive_failures": 0,
"last_connected_at": "2025-01-19T10:32:01.123Z",
"dropped_events": 0,
"backfill_failures": 0,
"heal_state": "healthy",
"capture_enabled": true
},
"dependencies": [
{
"name": "postgres",
"state": "healthy",
"check": "tcp localhost:5432",
"start_invoked": false
}
],
"warnings": [
{
"code": "mkcert_ca_untrusted",
"message": "Note: the local CA is not installed in the system trust store.",
"hint": "run 'mkcert -install' and restart prox"
}
],
"warnings_sealed": true
}
proxy reports this project's shared-proxy health and is present whenever a proxy is configured (mode is disabled and the rest of the block is zero-valued when no proxy is configured):
| Field | Description |
|---|---|
mode |
shared (registered with the shared proxy daemon), standalone (this process runs its own in-process proxy listener), or disabled |
daemon_reachable |
Result of a live /health probe against the shared daemon (500ms timeout, cached 2s); always false outside shared mode |
daemon_version |
The shared daemon's reported version, when reachable |
consecutive_failures |
The forwarder's current run of failed reconnects to the shared daemon |
last_connected_at |
Last time the forwarder established an SSE stream to the shared daemon |
dropped_events |
Request-stream events lost to a full subscriber channel |
backfill_failures |
Post-connect ring snapshot fetch failures |
heal_state |
healthy, healing, or version_mismatch; empty when not in shared mode |
capture_enabled |
Whether request/response capture is effectively on (a proxy is running for this session and proxy.capture.enabled is true). Daemons predating this field omit it entirely; an absent capture_enabled means "unknown", not false |
prox status (the CLI command) renders this block as a Proxy: line and, when mode is shared and daemon_reachable is false, prints Proxy: DOWN — shared proxy daemon unreachable (proxied routes are dead). Check 'prox proxy status'. and exits with status 1 even though the project's own processes may be healthy. The project self-heals automatically (re-registers with a fresh or recovered daemon), worst case within ~45s — treat a brief daemon_reachable: false as transient rather than a hard failure. See prox status.
dependencies reports the resolution state of every configured dependencies: entry; the field is omitted entirely when no dependencies are configured (it is never an empty array):
| Field | Description |
|---|---|
name |
The dependency's name |
state |
pending (no resolution started yet this generation), checking, starting, polling, healthy, warned (budget exhausted, on_failure: warn), failed (budget exhausted, on_failure: fail), or canceled (a resolution torn down by shutdown/reset, not a readiness failure) |
check |
A one-line "<kind> <target>" summary of the readiness probe, e.g. tcp localhost:5432, url http://localhost:9070/health, or cmd ... (truncated with … past 60 characters) |
last_error |
The most recent probe error as a string; omitted when there is none |
start_invoked |
Whether the start: command was launched this generation (always false when the initial check already passed) |
Only failed trips the exit-1 contract (see prox status for the full precedence); warned/pending/checking/polling/healthy/canceled do not.
warnings carries this session's user-facing advisories (plan 028) — things prox noticed that are not failures, most of them raised somewhere the person who typed the command cannot see directly (inside the shared proxy daemon, whose own stdout/stderr are /dev/null, or inside a prox up -d child, whose stdout/stderr are .prox/prox.log). Omitted when there are none (never an empty array). Each entry:
| Field | Description |
|---|---|
code |
A stable machine identifier for the warning kind, e.g. mkcert_ca_untrusted or hostname_unresolved. Wording under message/hint may change; code does not |
message |
One or more complete, user-facing sentences describing what is wrong |
hint |
The next action to take, when there is one; omitted otherwise |
warnings_sealed reports whether every startup warning producer for this session has finished. Some producers (the hostname resolution check) run asynchronously and can still be in flight when a prox up -d parent takes its post-readiness status snapshot; polling this flag (rather than trusting a single fetch) is how that parent avoids silently losing a warning to the race. Always present (unlike warnings, it is not omitempty): false is itself the meaningful value a poller is waiting to flip.
A warning never changes any exit code. prox status, prox up, and prox up -d all render warnings the same way (Warning: <message>, with <hint> indented underneath) and none of their exit contracts consult it.
GET /processes¶
List all processes.
Response:
{
"processes": [
{
"name": "web",
"status": "running",
"pid": 12345,
"uptime_seconds": 3600,
"restarts": 0,
"health": "healthy"
},
{
"name": "api",
"status": "waiting",
"pid": 0,
"uptime_seconds": 0,
"restarts": 0,
"health": "unknown",
"waiting_on": ["postgres", "redis"]
},
{
"name": "migrate",
"status": "completed",
"pid": 0,
"uptime_seconds": 9,
"restarts": 0,
"health": "none",
"kind": "task"
}
]
}
Status values: running, stopped, starting, stopping, crashed, waiting (a gated process/task whose depends_on targets are still resolving), blocked (a gated process/task left terminal when a required target failed), completed (a task that exited 0)
Health values: healthy, unhealthy, unknown, none. none means no healthcheck: is configured, so prox never had a check to run — the prox status table renders it as -. unknown means a check is configured but has not reported a verdict yet: the process is stopped, crashed, not yet launched, or still inside its start_period. A task with no healthcheck reports none (the api and migrate entries above show the two cases side by side). Treat the list as open — a future prox may add a value, so clients should fall through to a neutral rendering rather than rejecting one they do not recognize.
| Field | Description |
|---|---|
kind |
"task" for a tasks: entry; omitted (meaning a plain process) otherwise |
waiting_on |
The depends_on targets a waiting process/task is still resolving, in declaration order. Omitted (never an empty array) outside the waiting state |
blocked_on |
The depends_on targets that failed and left this process/task blocked, in declaration order. Omitted outside the blocked state |
pid is 0 for any state with no live process — waiting, blocked, stopped, and completed all report 0 here (the CLI table renders this as -).
GET /processes/{name}¶
Get detailed process info.
Response:
{
"name": "api",
"status": "running",
"pid": 12345,
"uptime_seconds": 3600,
"restarts": 2,
"health": "healthy",
"healthcheck": {
"enabled": true,
"last_check": "2025-01-19T10:32:01.123Z",
"last_output": "OK",
"consecutive_failures": 0
},
"cmd": "go run ./cmd/server",
"env": {
"PORT": "8080"
},
"stop_timeout": "30s"
}
The healthcheck block is present only when a healthcheck: is configured; a process reporting "health": "none" omits it entirely. Its enabled field reports whether the check loop is currently running: false once the process has exited for any reason — stopped deliberately, crashed on its own, or completed — or before it has ever been launched, in which case health is unknown because the configured check has produced no verdict. A process that crashes on its own stops its checker (and flips enabled to false) the instant the exit is observed, not only once a later stop request arrives (plan 028, #107) — so a crashed process's check command is not still re-executing against a dead pid in the background.
stop_timeout is the effective SIGTERM→SIGKILL escalation budget in force for this process (its own stop_timeout, else the global shutdown_timeout, else the 10s default), as a duration string. It is the budget governing a POST /processes/{name}/stop or POST /processes/{name}/restart. See Stop Timeout.
POST /processes/{name}/start¶
Start a stopped process.
prox.yaml is re-read first and the process is launched with its current config — see the reload behavior documented under restart below (it applies identically to start).
Response:
POST /processes/{name}/stop¶
Stop a running process.
Response:
POST /processes/{name}/restart¶
Restart a process (stop then start).
Config reload: before launching, prox re-reads and validates the whole prox.yaml and applies the target process's current config. Applied on (re)start: cmd, healthcheck, stop_timeout (the new value governs the next stop; the restart's own stop half uses the pre-edit budget), and environment inputs (inline env, per-process and global env_file, including changed file paths). NOT applied (require prox up): renames, added/removed processes, and services/proxy/port changes.
The reload is fail-closed: an invalid file (even an unrelated process or the proxy section), a missing referenced env file, or a removed target aborts the (re)start with an error and leaves the existing process running unchanged:
CONFIG_RELOAD_FAILED(HTTP 422) — the file could not be read/parsed/validated.PROCESS_NOT_IN_CONFIG(HTTP 409) — the target process is no longer in the file; runprox upto reconcile.
Response:
GET /logs¶
Retrieve logs from buffer.
Query Parameters:
| Param | Type | Default | Description |
|---|---|---|---|
process |
string | all | Comma-separated process names |
lines |
int | 100 | Max lines to return |
pattern |
string | — | Filter pattern |
regex |
bool | false | Treat pattern as regex |
The lines parameter is capped at 10000.
Response:
{
"logs": [
{
"timestamp": "2025-01-19T10:32:01.123Z",
"process": "web",
"stream": "stdout",
"line": "GET /api/users 200 12ms"
}
],
"filtered_count": 100,
"total_count": 4523
}
GET /logs/stream¶
Stream logs via Server-Sent Events (SSE).
The stream is long-lived: it is exempt from the 30s request-timeout class and ends only on client disconnect or daemon shutdown.
Query Parameters: Same as GET /logs (except lines)
Response: SSE stream
data: {"timestamp":"2025-01-19T10:32:01.123Z","process":"web","stream":"stdout","line":"GET /api/users 200 12ms"}
data: {"timestamp":"2025-01-19T10:32:01.456Z","process":"api","stream":"stderr","line":"WARN: connection pool low"}
Example:
curl -N http://localhost:5555/api/v1/logs/stream
curl -N "http://localhost:5555/api/v1/logs/stream?process=web,api"
curl -N "http://localhost:5555/api/v1/logs/stream?pattern=ERROR"
GET /proxy/requests¶
Retrieve recent proxy requests (requires proxy to be enabled).
When the project runs under the shared daemon, its request data is bridged from the daemon over the internal socket. On every (re)connect the bridge backfills a snapshot of the daemon's current ring while continuing to consume the live stream, closing gaps opened while the subscription was down (bounded by the daemon ring and the project's registration lifetime). Snapshot replay and live delivery are concurrent; monotonic same-ID upserts keep stale copies from regressing a record, and reconnects re-deliver no already-seen record, so this endpoint stays free of duplicates.
Query Parameters:
| Param | Type | Default | Description |
|---|---|---|---|
subdomain |
string | all | Filter by subdomain |
method |
string | all | Filter by HTTP method (GET, POST, etc.) |
min_status |
int | — | Minimum status code |
max_status |
int | — | Maximum status code |
since |
string | — | RFC3339 timestamp; only requests recorded at or after this time |
url_contains |
string | — | Case-insensitive substring match against the request URL (path+query only — never scheme/host) |
before_id |
string | — | Cursor: page strictly older than this request ID (see Cursor pagination below) |
limit |
int | 100 | Max requests to return (max 5000 — the ring size) |
Response:
{
"requests": [
{
"id": "a1b2c3d4e5f6",
"timestamp": "2025-01-19T10:32:01.123Z",
"method": "GET",
"url": "/api/users",
"subdomain": "api",
"hostname": "api.local.dev",
"status_code": 200,
"duration_ms": 45,
"remote_addr": "127.0.0.1"
}
],
"filtered_count": 50,
"total_count": 250,
"next_before_id": "9f8e7d6c5b4a"
}
hostname is the request's Host header with the port stripped (e.g.
api.local.dev); it is omitted when not recorded (older records, or a
daemon-side record that predates this field).
in_flight is true on a request whose response is still streaming (the
backend has sent headers but the body hasn't finished); it is omitted
entirely once the request completes. duration_ms stays 0 while
in_flight is true — read it only once the field is gone. A request that
has been in_flight for more than 5 minutes also carries "stale": true —
this means the completion event may have been lost and the true outcome is
unknown, not that the request is broken: long-lived streams (SSE,
WebSocket-over-HTTP) and large transfers can legitimately sit at
stale: true for a long time while still live. stale is omitted once the
request completes.
Cursor pagination¶
Pass the previous page's next_before_id as before_id to fetch the next
older page. next_before_id is the ID of the oldest scanned record, not
the oldest returned one — a page that gets filtered down to zero results
still advances the cursor instead of stalling. It is omitted when the scan
reached the end of the ring (no more history).
Ring order is arrival order, not strict timestamp order (backfill and a
completion re-appending after its in-flight record was evicted can both put
a newer-by-timestamp record after an older one). An unknown, evicted, or
out-of-scope before_id — including a record from a different
?subdomain=/project scope — returns 410 Gone with code
CURSOR_GONE; on that response, restart pagination without a cursor rather
than retrying the same one.
Example:
# Get all recent requests
curl http://localhost:5555/api/v1/proxy/requests
# Filter by subdomain
curl "http://localhost:5555/api/v1/proxy/requests?subdomain=api"
# Filter for errors (5xx)
curl "http://localhost:5555/api/v1/proxy/requests?min_status=500"
# Filter by URL substring (path+query, case-insensitive)
curl "http://localhost:5555/api/v1/proxy/requests?url_contains=/api/users"
# Page backward through history
curl "http://localhost:5555/api/v1/proxy/requests?before_id=9f8e7d6c5b4a"
GET /proxy/requests/{id}¶
Retrieve details for one proxied request.
| Param | Type | Default | Description |
|---|---|---|---|
include |
string | — | Set to body to include captured request and response body data |
Body data is available by default whenever the project's proxy is enabled — capture is on unless the project set proxy.capture.enabled: false or ran with --no-capture — but is not guaranteed for every record: a body may be absent when capture is unavailable in the daemon (capture_available: false in daemon status), after disk-budget eviction, or once the record ages out of the 1000-record captured-body window even though the ring keeps 5000 records, and the record still carries its metadata and headers. In shared mode this endpoint serves the project's own local copy of the ring, which enforces that 1000-record body bound itself (by request timestamp, since the daemon's own eviction publishes no event this endpoint could react to) rather than relying on the daemon to have already stripped the record. All of those report unavailable_reason: "evicted" (below). Stored request_headers/response_headers values — including Authorization, Cookie, and sensitive query params such as inside a Location/Referer header value — are captured verbatim, with no values altered or hidden. Bodies are likewise captured verbatim, up to max_body_size. See Request Capture for the full cleartext posture and the disk-budget/eviction rules.
Response:
{
"id": "a1b2c3d4e5f6",
"timestamp": "2025-01-19T10:32:01.123Z",
"method": "POST",
"url": "/api/users",
"subdomain": "api",
"hostname": "api.local.dev",
"status_code": 201,
"duration_ms": 45,
"remote_addr": "127.0.0.1",
"details": {
"request_headers": {
"Content-Type": ["application/json"]
},
"request_body": {
"size": 27,
"captured_size": 27,
"truncated": false,
"content_type": "application/json",
"is_binary": false,
"data": "{\"name\":\"Ada\"}"
}
}
}
Captured body fields:
| Field | Description |
|---|---|
size |
Total encoded bytes observed by the capture wrapper before truncation (not Content-Length, not the decoded size) |
captured_size |
Encoded bytes actually retained; truncated is true when captured_size < size |
truncated |
Whether the body exceeded the 1MB capture cap and was truncated |
content_type |
Captured Content-Type header value |
content_encoding |
Captured Content-Encoding header value (e.g. gzip); omitted when unencoded |
is_binary |
With include=body: whether the served (post-decode) bytes are binary. Without it: the stored classification of the raw wire bytes (a gzip body reports true here even when its decoded form would serve as text) — see decoded-body semantics below |
data |
Body content (only with include=body): plain text for text bodies, base64 for binary |
unavailable_reason |
Set (e.g. evicted) when include=body was requested but the body could no longer be loaded — its spilled file was disk-budget evicted, or the record aged past the newest-1000 captured-body window; data is absent, while metadata and headers remain |
Decoded-body semantics: captured bodies store the raw wire bytes and are decoded at serve time. Supported content_encoding values are gzip/x-gzip, deflate (zlib-wrapped per RFC 9110, with a fallback to raw deflate for servers that send it unwrapped), zstd, and br (brotli) — decoded automatically, capped at 10MB decoded size (MaxDecodedBodySize). When the body was not truncated and decodes cleanly, is_binary reflects the decoded content (readable JSON/text decodes to is_binary: false). Chained encodings (e.g. gzip, br), unrecognized tokens, truncated bodies, corrupt streams, and payloads whose decoded size would exceed the cap are served as the raw bytes, base64-encoded, with is_binary: true and content_encoding preserved. The stored (raw) binary classification and the served is_binary may therefore legitimately differ. The on-disk path of a spilled body is never exposed.
Examples:
curl http://localhost:5555/api/v1/proxy/requests/a1b2c3d4e5f6
curl "http://localhost:5555/api/v1/proxy/requests/a1b2c3d4e5f6?include=body"
GET /proxy/requests/stream¶
Stream proxy requests via Server-Sent Events (SSE).
The stream is long-lived: it is exempt from the 30s request-timeout class and ends only on client disconnect or daemon shutdown.
Query Parameters: Same as GET /proxy/requests (except limit), including url_contains
Response: SSE stream
: connected
data: {"id":"a1b2c3d4e5f6","timestamp":"2025-01-19T10:32:01.123Z","method":"GET","url":"/api/users","subdomain":"api","hostname":"api.local.dev","status_code":200,"duration_ms":45,"remote_addr":"127.0.0.1"}
A proxied request emits two data: events sharing the same id: a
start event the moment the backend's response headers arrive
("in_flight":true, duration_ms still 0), and a completion event once
the body finishes (no in_flight field, final duration_ms). There is no
event: type field distinguishing them — consumers that key by id see a
clean upsert; naive line-oriented consumers see one extra data: line per
request. Early routing failures (no matching subdomain) emit only the
completion event, since nothing was proxied.
Example:
curl -N http://localhost:5555/api/v1/proxy/requests/stream
curl -N "http://localhost:5555/api/v1/proxy/requests/stream?subdomain=api"
curl -N "http://localhost:5555/api/v1/proxy/requests/stream?url_contains=/api"
POST /shutdown¶
Gracefully shut down supervisor and all processes.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
wait |
boolean | When exactly true, block until the process-stop verdict lands and return it (see below). Any other or absent value uses the legacy async behavior. |
This route is in the lifecycle timeout class (the same long hang-protection ceiling as start/stop/restart), because the wait=true path can block for the full drain.
Legacy async response (default, wait absent or not true):
The daemon acks immediately, then tears down in the background. The connection closes as the supervisor terminates.
Waited response (wait=true):
The request blocks until every process has been stopped and its group reaped, then returns the verdict. success is true only when failures is empty. When a process group survives shutdown, the response still uses HTTP 200 (so the structured body is not discarded) and lists each survivor:
{
"success": false,
"waited": true,
"failures": [
{
"process": "web",
"error": "process group could not be terminated: web",
"code": "PROCESS_GROUP_NOT_REAPED"
}
]
}
The code field is a stable machine-readable classifier (PROCESS_GROUP_NOT_REAPED). The waited field is present only on this path; a daemon that predates the wait parameter ignores it and returns the legacy body without waited, letting clients detect the older daemon.
The daemon tears down in stages, each with its own deadline: the launch gate is closed, the proxy is deregistered/stopped on a short fixed deadline, then every process is stopped concurrently, each on its own configured stop budget (see Stop Timeout); the verdict is then published to any wait=true caller, the logs are flushed and closed, and finally the API server is stopped. Graceful timing therefore derives from the configured shutdown_timeout / stop_timeout values, not a single fixed window.