lazy documentationv0.91

Reaching a task's dev server

A task runs in its own environment. When the work is a web app, that environment has a dev server in it — and until you can open it in a browser, the task is only half usable.

Declare the ports your project serves on, and every task's dev server gets its own name in the browser. lazy url tells you where.

# lazy.toml
[serve]
ports = [3000, 5173]
lazy start my-task
lazy url my-task
# 3000  http://3000.my-task.lazy.localhost:26024  ● listening
# 5173  http://5173.my-task.lazy.localhost:26024  ○ not listening

open "$(lazy url my-task 3000)"

The dot is a liveness check: ● means a plain TCP connect to the port the container publishes succeeds — something is listening behind the URL right now; ○ means the port is published but nothing answers yet (the dev server has not started, or has stopped). The check connects and immediately closes without sending a byte, so it never counts as a request to your app. With a service argument (lazy url my-task 3000) the output stays exactly one bare URL, so it keeps composing in scripts.

Naming services

With more than a port or two, the number stops being a good name. Give them one:

[serve.services]
web = 3000
api = 8080
storybook = 6006
lazy url my-task            # web  http://web.my-task.lazy.localhost:26024
                            # api  http://api.my-task.lazy.localhost:26024
                            # ...
lazy url my-task web        # http://web.my-task.lazy.localhost:26024

Both spellings can be used together — ports for the ones whose number is the name, [serve.services] for the ones worth naming. A bare port from ports is addressed by its number (lazy url my-task 5173); a named service is addressed by name or by its container port, case-insensitively. Service names must start with a letter, so a name is never mistaken for a port.

The same mapping shows up in lazy show, and in the web UI as a Services card on both the task detail page and the review page: each service with its URL as a link, a green/grey liveness dot from the same TCP check lazy url uses, and the lazy url <task> <service> command ready to copy. The task list does not show it: the live mapping means a runtime call per task, which is not a cost a list render should pay for information nobody asked for yet.

The URL is a name: <service>.<task>.lazy.localhost

That URL is served by the running lazy daemon, on the same port as the dashboard. The daemon reads the hostname, finds the task's container and the port it publishes, and forwards the request there — including WebSockets, so hot reload and ActionCable work through it.

Two things that gets you, both of which a bare 127.0.0.1:49154 cannot:

The task part is the task's code when it has one, and its short id otherwise (http://web.a1b2c3d4.lazy.localhost:26024). That is why task codes are restricted to what a hostname allows — lowercase letters, digits and hyphens, up to 63 characters. Older tasks whose code does not fit keep it and keep working everywhere else; in a URL they are addressed by short id, which is what lazy url prints for them. The service part is the service name, or the container port for a bare [serve] ports entry.

--direct for curl and scripts

Chromium and Firefox resolve *.localhost to loopback themselves, so nothing has to be installed for a browser to reach these names. The OS resolver usually does not, and a wildcard cannot go in /etc/hosts — so curl, an HTTP client in a script, and anything else that is not a browser needs the raw mapping:

curl "$(lazy url my-task web --direct)"   # http://127.0.0.1:49154

--direct works in both shapes — with a service argument it prints the one bare loopback URL, and without one it lists them all.

When nothing is listening

Opening a task service in a browser when the dev server is not up does not give you the browser's own "connection refused" page. The daemon knows why it could not forward, so it sends you to that task's page in the web UI with a banner saying which service you opened and what is wrong — the container is not running, or it is running and nothing is listening on that port — followed by the same Start container and Start services buttons the page already has, and a Try again link back to the URL you tried.

Only browser navigations are redirected. An asset, an XHR or a fetch gets a 502 with a one-line explanation instead, because answering those with an HTML page produces a confusing failure rather than a clear one.

Nothing on that page starts anything by itself. Pressing a button does; opening a URL never does, and no page you visit elsewhere can start a container by embedding a task URL.

What the host port is, and why it isn't 3000

Every task gets an OS-assigned host port (docker's -p 127.0.0.1:0:3000), not a fixed one. That is what makes five tasks that all serve on 3000 able to run at once — a fixed host port would mean the second task's container fails to start. The runtime is the source of truth for the mapping; lazy url asks it, so a mapping is never stale.

The binding is loopback only. A task's dev server is for the person driving the task, not for the coffee-shop network the laptop is on.

Changing [serve] takes effect on the next container

Published ports are fixed when a container is created — neither docker nor podman can add a mapping to a running container. So editing [serve] does not change a task that is already up. lazy show says so plainly:

  Serving:
    web → http://web.my-task.lazy.localhost:26024
    api → (not published — restart to pick up [serve])

To pick the change up on a task that is already up:

lazy shell my-task --restart

That recreates the container with the current [serve] and drops you into it — so anything running inside (including a dev server) stops. It is refused while the agent is mid-turn; lazy stop <task> first, or wait for the turn to end.

[serve] is read from the project root's lazy.toml, like every other setting — a copy inside a task worktree is ignored. A branch that adds a service gets it once the change is on the root, which is also the config a task's container is created from. The Services tab on the task page still shows those branch-only ports, each with lazy forward <task> <port> so you can reach them while the container is up, without treating the worktree file as config.

One-off ports: lazy forward

[serve] is for the ports a project always serves on. For the ones you want once — a database you feel like poking at, a debug server, a port you never declared — there is lazy forward:

lazy forward my-task 5432
# Forwarding 127.0.0.1:49231 -> lazy-my-task:5432
#
# Forwarding while this command runs. Press Ctrl-C to stop.

It runs in the foreground and forwards for exactly as long as it runs. Ctrl-C closes every listener and every connection it was carrying; nothing is left published, and nothing is registered anywhere to clean up later.

Each argument is a port pair:

lazy forward my-task 3000                 # container 3000 → an assigned host port
lazy forward my-task 8080:3000            # container 3000 → 127.0.0.1:8080
lazy forward my-task 8080:3000 5433:5432  # as many pairs as you like

A bare number means "assign me a free host port", which is the spelling that can never collide with something already on your machine. Either way the resolved address is printed as soon as the listener is up, one line per pair.

Like [serve], the listener is loopback only — a forwarded port is never exposed to the network the machine is on.

Two things it deliberately does not do. It never starts a container: a freshly created one has nothing listening yet, so a forward into it would be dead on arrival. Bring the task up with lazy shell my-task first, start whatever should be listening, then forward in another terminal. And it does not show up in lazy url, which reports what the container publishes — a forward lives only in the terminal you started it in.

If the task's image is old enough to predate lazy shipping socat (which is how a forwarded connection reaches a port inside the container), the command says so up front and points at lazy upgrade --images.

Working inside the task's environment

The typical loop: get a shell in the container, set things up, start the server, open it.

lazy shell my-task      # a shell inside the task's container
# ... npm install, npm run dev ...

# in another terminal
lazy url my-task web

lazy shell <task> opens a shell inside the task's container by default: the same node, the same installed packages, the same network as the agent sees. lazy shell <task> --host gives you a shell in the task's worktree on your own machine instead, which is the right thing for git and host editors. (--container is still accepted as a deprecated no-op alias for the default.)

If the task's container is not running, lazy shell starts it first and says so — which also means the container is created fresh, picking up the current [serve].

Runners without containers

Everything on this page needs a container runner (docker or podman): task servers are reached through the container's published ports. The dangerously-host-process-without-any-isolation runner has no container and so no port mapping — a dev server a task starts there listens directly on your machine.