Skip to content

Named URLs — the vocabulary

This content is for v0.28. Switch to the latest version for up-to-date documentation.

Three different things were being called "the proxy": the server that makes names resolve, the OS rule that removes a port from those names, and the port either of them happens to use. They are separate mechanisms with separate failure modes, and confusing them makes every message about them unreadable. This page fixes the words. Code, constants and CLI copy use these and no synonyms.

Term What it is Where it lives
the proxy The reverse proxy the daemon runs. It listens on one port and routes by Host header to the job answering under that name. This is what makes names exist at all. On by default; [proxy] enabled = false in the global config switches it off internal/service/proxy
bind port What the proxy actually listens on: 11080, or the next free port when that one is taken (the R7 fallback) rules.ProxyPort, then the daemon's own fallback
privileged redirection The OS rule wtm run proxy install installs — on macOS a launchd agent binding :80 and relaying to the bind port. It only removes the port suffix from a URL. It unlocks nothing internal/service/proxy/redirect*.go
public port What a URL announces, as opposed to what anything binds: nothing (i.e. :80) when the redirection is live, the bind port otherwise rules.PublicPort
route host <job>.<worktree>.<project>.localhost — the name a published job answers under rules.RouteHost
origin scheme://host[:port] — the thing a browser sends in Origin: and CORS compares. The port is part of an origin; it is not part of a cookie's origin rules.JobOrigin
addressing ports or names: which of the two an [[env_port]] link writes into a .env value, and which of the two a run announces rules.EffectiveAddressing, rules.RunProxyPort

The proxy makes names work. The redirection makes them pretty. *.localhost resolves to 127.0.0.1 natively (RFC 6761), but resolving is not answering — without the proxy listening, a name gives connection refused. Without the redirection, the same name works perfectly, carrying :11080.

State What a .env holds Works?
proxy on, redirection not installed http://api-dev.feat-x.monorepo.localhost:11080 yes
proxy on, redirection installed http://api-dev.feat-x.monorepo.localhost yes
proxy off (enabled = false) http://localhost:4011 yes, without names

A port in the URL costs nothing that matters. CORS compares whole origin strings, so as long as both sides carry the same one — port included — it passes. And cookie isolation, the reason named URLs exist, keys on the host and ignores the port entirely: feat-x and feat-y are separate jars whether or not :11080 is there.

A run announces what the .env files spell. Under ports a run is opened with no proxy port (rules.RunProxyPort, through seam.ProxyPortsFor): it registers no route, and run up, run ps, run url, run open and the run view's reach pane hand out http://localhost:<port> — while the machine's proxy keeps running for any other project. Announcing the name there would point the reader at an origin the app's CORS settings and API urls know nothing of.

Addressing is a project setting, the proxy is a machine setting. run.toml says what the project wants; the global config says what this machine can do. When the machine cannot honour it — proxy off, no public port — ports are written and a notice says so. Writing a name nothing serves would produce a syntactically perfect, dead value.

addressing = "names" # the default — omit it

Under names, an [[env_port]] link writes the job's full origin instead of its port number, but only when two conditions hold at once:

  1. the job it names publishes a url, and that url publishes the very port the link follows;
  2. the value in the .env has the shape of a URL.

Both are load-bearing. The first excludes Postgres — DATABASE_URL → docker-compose.POSTGRES_PORT has no name and never will, since the proxy only speaks HTTP. The second excludes the binding keys: apps/api/.env: PORT → api-dev.PORT names a published job and must stay a bare number. Neither condition alone is enough.

apps/api/.env PORT=4001 → 4011 (bare number)
apps/web/.env VITE_API_URL → http://api-dev.feat-x.monorepo.localhost:11080
CORS_ORIGIN → http://web-dev.feat-x.monorepo.localhost:11080
DATABASE_URL → …@localhost:5442/db (no name)

addressing = "ports" is the escape hatch, and it is a real inverse: a value wtm wrote as an origin is recognised as such and rendered back to http://localhost:<base+offset>.

wtm run addressing is how it is switched (flow/run/addressing, Switch). Its two questions are steps of one session — the mode, then whether to settle the worktrees — so the wizard can go back from the second to the first. The second reads every worktree's plan under the mode just picked, before run.toml is written: ResolveEnvPortsParams.Addressing overrides the file's, and the count is cached per mode so going back does not reread every .env. The settle itself is envports.Settle, the pass create and extract run, so the three cannot write different values.

Which worktrees it takes is the one place the main checkout is treated apart, and the rule is rules.BulkSettlesMain: a pass over every worktree brings main back to ports, and never moves it onto names. See the next section for why main is left alone; the asymmetry is what keeps the two commands consistent with it:

Command Linked worktrees Main
wtm env <worktree> aligned on the mode aligned on the mode — naming it is the choice
wtm run addressing ports settled settled: ports is the state main has without wtm
wtm run addressing names settled left as is, and said so — wtm env main is its own decision

Without the return leg, wtm run addressing ports after a wtm env main would leave main on names under a project that says ports, and silently: the drift warning only reads a project on names. The condition lives in the command's choice of worktrees, not in the pass — the plan and the drift reading stay free of any main-shaped condition, as below.

A value already carrying a route host is recognised structurally — the authority matches <job label>.<anything>.<project label>.localhost — rather than remembered in a state file. One primitive then answers four questions that would otherwise each need their own mechanism:

  • a second wtm env recomputes the same origin, so nothing is rewritten twice;
  • a .env copied from main or a parent carries that worktree's segment, which is corrected to this one;
  • installing the redirection after the fact changes the public port, and the next pass drops the :11080;
  • switching to addressing = "ports" knows which values were wtm's to undo.

A state file would be more precise on paper and worse in practice: it diverges the moment somebody edits a .env by hand.

A named URL reaches the job with a Host the dev server has never seen, and some servers refuse one they do not recognise. Two of them matter here, and they do not need the same thing:

  • Next refuses it unless the host is listed in allowedDevOrigins. wtm run up reports the missing entry when it serves a job whose directory holds a next.config.* without one (rules.NeedsDevOrigins, rules.DevOriginsPattern).
  • Vite needs nothing. Its host check allows localhost and every .localhost subdomain outright, before server.allowedHosts is even consulted — verified in Vite 8.2.2, isHostAllowedInternal. There is nothing for wtm to detect or report.

Anything else is a report from a user, not a guess from us: the presence of a config file is the signal, never a framework inferred from a command line.

Under names, the named URL becomes the only working entrance. Opening localhost:5183 directly sends an Origin the API no longer knows, and CORS blocks it. wtm run url and wtm run open hand out the right link; a bookmark on the raw port stops working. That is the cost of having two worktrees stay logged in at the same time.

The spec that introduced names said the main checkout stays on ports. No code enforces that, and none should. Nothing in the port pass tests the ordinal, the base branch, or whether a checkout is the main one: worktree.List comes from git worktree list, which includes it, so wtm env main is accepted and writes named origins like anywhere else. The port substitution is the identity there (offset 0); the origin rewrite is not — it replaces an authority, which has nothing to do with the offset.

What "main stays on ports" really means is that no command provisions it: create and extract write the new worktree, and there is no such event for main. That is a gap in the lifecycle, not a guard in the code — so the answer is a warning, not a refusal.

rules.PendingOriginRewrites counts what a wtm env on a worktree would still move onto a named origin, rules.AddressingDriftLine/Lines phrase it, worktree.EnvPortPlanFor computes the plan without applying it (the same one wtm env writes, so the two cannot disagree), and flow/run/addressing is what the run flows read. They hand the lines to the surface, which renders them once where they can be seen: a band in the run view, a callout beside a stream, nothing at all on a machine run. A notice printed after the view reaches a reader who has already followed the URL.

Two things the count is deliberately not. It is not "the .env holds ports": a value already carrying a named origin whose public port went stale — every worktree, the moment proxy install moves 11080 to 80 — is pending too, and equally broken, which is why the wording says out of step rather than naming ports. And it is not "the app is broken": wtm sees only the keys declared as [[env_port]] links, and cannot know whether the app makes a cross-origin call at all. The reading names no worktree in particular: a linked worktree whose port pass was declined is in the same state, and main is only the one that is there by construction.

Do not add a main-shaped condition here.

The address a surface hands out is the name, whatever the .env spells. It used to follow the file — a worktree still on ports was handed http://localhost:<port> everywhere — and the result was that the one URL a reader came for disappeared on the checkout they use most, with the reason in a callout at the very bottom. Now every surface hands out the published name, and a worktree whose .env is out of step gets one ! line naming the command that aligns it (rules.AddressingDriftLine; rules.AddressedByPort only picks which of its two sentences). The trade is explicit: until that command runs, a cross-origin call made through the name is refused, and the line is what says so. --raw on run url / run open still gives the port.

Where to reach it — one model for every surface

Section titled “Where to reach it — one model for every surface”

A run says where each job is reached in one place, rules.ReachBlock over domain.ReachEntry (internal/rules/reach.go): the URLs first — a runner's by the apps it holds — then the jobs reached by port, their ports named (REDIS_PORT → redis) and wrapped in balanced columns, a shared job's namespace beside it. Every surface renders that block at its own density, so none of them decides on its own what to show:

Surface Line / title The block
stream (run up -d, a pipe) one fragment per job — the URL, :5432, 3 urls, 6 ports (rules.ReachSummary) concludes the run, before the ! lines and the hints
run view pane title job · status · fragment behind a, and in the recap left on exit
run ps ADDRESS column, WORKTREE as the branch —

A shared service a linked worktree only holds runs in main, and every surface says so the same way: postgres joined, running in main on its line, a Shared, running in main section in the block (rules.ReachSections), a shared line above it in the run view's job list, and a pane title naming where it runs rather than the worktree holding it. Stopping the worktree leaves those running — which is the one thing the reader has to be able to see.

  • internal/rules/envorigins.go — the whole origin surgery, pure and testable
  • internal/rules/envports.go — the port substitution it sits beside
  • architecture.md — which layer may call which