# Hallway > The front door for everything you vibe-coded on a random port. A small, always-on, local-only service for a developer's own machine (macOS and Linux, and Windows best effort): a live dashboard of every local web server (declared, discovered from other tools, or simply found listening), with health, uptime and bookmarks; native user services (launchd, systemd, or a scheduled task on Windows) for the apps that should survive a reboot; and, on macOS and Linux, memorable HTTPS hostnames via Caddy. **Status: v0 is released** (`npm i -g hallway`, Node 22.13+). Each feature is marked v0 (available now), v1 or v2 (upcoming); see https://hallway.edspencer.net/roadmap/. Every page below is also available as Markdown at the same URL with `.md` instead of the trailing slash. --- # Introduction > What Hallway is, the problem it solves, and what shipped when. import { CardGrid, LinkCard } from '@astrojs/starlight/components'; import { DEFAULT_PORT } from '../../site.mjs'; **Hallway is the front door for everything you vibe-coded on a random port.** It is a small, always-on, local-only service for your own machine. It keeps a live directory of every web server you run locally: - the long-lived ones you installed on purpose, - the ad-hoc ones a coding agent started in a session that has since ended, - and the unidentified ones it finds simply by looking at what is listening. It shows them on one calm dashboard with their health, uptime history and a thumbnail, and doubles as a personal jumping-off page of bookmarks to the remote things you always forget the URL of, and it can start a dev server as a native OS service so it outlives the session (and, if you ask, comes back after a reboot). If you want, it gives each app a memorable HTTPS hostname too (`slides.localhost` instead of a port number nobody remembers). :::note[What's released] **Hallway is on npm**: see [Installation](/getting-started/installation/). Each feature on this site is marked with the milestone it belongs to (v0, v1, v2), and all three have shipped. What's left is checking everything on a real Mac: see the [roadmap](/roadmap/). ::: ## The problem Coding agents have made small web apps nearly free to build: a dashboard for some data, a slide deck, a log viewer, an internal tool. Each is an HTTP server on some port, and the way it gets started is the root of the mess: 1. A coding-agent session (or a terminal tab) runs `npm run dev`, `vite`, `astro dev`, `next dev`, `python -m http.server` or similar. 2. The server keeps running after the session ends. It is now parented to nothing you can see. 3. Days later you can't remember that it exists, which port it is on, which project it belongs to, or what started it. 4. A reboot kills it. Nothing restarts it. You find out when you next go looking. This is not hypothetical. One developer found six dev servers on ports 4321, 4455, 4666, 4670, 4671 and 7333, one to two weeks old. Three had command lines like `node dist/index.js` that said nothing; three were the same dashboard running from three git worktrees, two of them pushed off its hardcoded port by hand. Then a reboot killed all six, and nobody noticed for two days. By then there was no record of what had been running, so nothing could say what was lost. There is a second, related need: one page that links to the remote things you visit but never remember the URL of, such as production admin consoles, BI dashboards and status pages. On a home network a home-lab dashboard does that. There is no equivalent for "my laptop". ## What makes it different Plenty of dashboards show tiles and bookmarks. Hallway's differences: 1. **It populates itself.** It reads registries and *scans for listeners*, so the page reflects reality without anyone maintaining a config. Every hand-maintained dashboard recreates the original problem. 2. **It remembers.** It records what it saw while processes were alive, so after a reboot it can say what was lost (**Lost at last reboot**). 3. **It fronts**, if you ask it to. Memorable HTTPS hostnames per app, with defaults that need no DNS server. 4. **It keeps things alive.** Native launchd (macOS), systemd user (Linux) or scheduled-task (Windows) services, no extra process manager. 5. **It is built for agent-spawned servers.** Plain YAML an agent can edit, an agent guide, an MCP server, and one command, `hallway run`, that starts a server so it never becomes an orphan. ## What shipped when - v0 **The front page**, released in 0.1.0 for macOS and Linux. The daemon, the dashboard at {`http://localhost:${DEFAULT_PORT}`}, YAML config with live updates, health checks with 24-hour uptime bars (7- and 90-day views in the drawer), the port scanner with claim and ignore, bookmarks, favicon tiles, the agent guide, `hallway doctor` for the daemon and data, and running Hallway itself as a login service. Links go to `http://localhost:`. No sudo, no DNS, no proxy. - v1 **Fronting and services**, released in 0.1.0 to 0.3.0. [`hallway run`](/agents/hallway-run/), `stop`, `start`, `restart` and `logs`, managed services for apps (launchd or systemd user units) and log rotation; [fronted mode](/networking/fronted-mode/) (Caddy with HTTPS on `*.localhost`, `hallway setup` and `teardown`, networking checks in `hallway doctor`, the fallback page); [custom domains](/networking/custom-domains/) such as `myname.internal`; and [Lost at last reboot](/concepts/services/#lost-at-last-reboot). - v2 **Agents and polish**, released in 0.2.0 and 0.3.0: the [MCP server](/agents/mcp/), the optional [dev-server hook](/agents/hook/), opt-in screenshot [thumbnails](/concepts/thumbnails/), generated cards, and a fast 90-day uptime view with a latency chart. What's left is running every criterion on a real Mac. - **Windows, best effort**, released in 0.4.0 (the daemon, dashboard and port scanner) and 0.5.0 (managed services as scheduled tasks). Everything but fronted mode, checked on CI's Windows runners but not yet on a real Windows desktop. See [the roadmap](/roadmap/#windows). ## Who it's for One developer, on their own machine, running many small local web servers, especially one who leans on coding agents. On macOS or Linux, or on Windows without the HTTPS names. Comfortable with a CLI and with editing YAML; not interested in maintaining a hand-written dashboard config. Hallway is **not** for teams, remote access, production process management or containers. --- # Installation > Installing Hallway from npm on macOS, Linux or Windows, and what it needs. ## Install v0 Two commands: ```sh npm i -g hallway # Node 22.13 or later hallway service install # run the daemon at every login ``` Then open `http://localhost:4255`, or run `hallway open`. ### Just trying it? ```sh npx hallway # the dashboard, in the foreground ``` That runs Hallway from npx's cache until you close the terminal. It's the quick way to try it, not to keep it: npm can delete that cache at any time, so `hallway service install` refuses to run from it (and `hallway doctor` flags a service that already does). To keep it, install it with the two commands above. `hallway service install` registers the daemon with your OS's own service manager and starts it, so the dashboard is always there without you starting anything: - **macOS:** a LaunchAgent, `~/Library/LaunchAgents/hallway.daemon.plist`, loaded now and at every login. - **Linux:** a systemd user unit, `~/.config/systemd/user/hallway-daemon.service`, enabled and started. A user unit runs while you are logged in; to keep it running without a login session (and from boot), enable lingering once with `loginctl enable-linger $USER`. Hallway never does that for you, and `hallway doctor` warns while it is off. - **Windows** (best effort): a scheduled task, `\hallway.daemon`, that starts when you log on and runs `hallway supervise`, which keeps the daemon running and writes its logs. Its files are in `%LOCALAPPDATA%\hallway\tasks\`. It runs while you're logged on. No administrator rights are needed, and none are asked for. This has run for real on GitHub's Windows runners, but a real logon starting it hasn't been tried on a Windows desktop yet. It is idempotent: run it again any time. Undo it with `hallway service uninstall`; `hallway service status` says whether it is running. Finally, tell your coding agents about it. This prints a short paragraph to paste into an agent instruction file such as `CLAUDE.md` or `AGENTS.md`: ```sh hallway guide --snippet ``` Optional extras, each on its own page: memorable HTTPS names such as `https://my-app.localhost` with [`hallway setup`](/networking/setup-and-doctor/), the [MCP server](/agents/mcp/) and the [dev-server hook](/agents/hook/) for coding agents, and [screenshot thumbnails](/concepts/thumbnails/). To try Hallway without installing it as a service, run the daemon in the foreground instead: ```sh hallway # same as `hallway serve`; Ctrl-C to stop hallway open # open the dashboard in your browser ``` ## Requirements | | | | --- | --- | | **OS** | macOS (launchd) or Linux (systemd user units). Windows is best effort (per-user scheduled tasks): everything but fronted mode works; see [the roadmap](/roadmap/#windows) for what hasn't been checked on a real Windows desktop. | | **Node** | 22.13 or later (for the built-in `node:sqlite` that holds uptime history). | | **Privileges** | None. The daemon never runs as root, and nothing asks for sudo except `hallway setup` and `hallway teardown`. | | **For fronted mode** v1 | Optional, and macOS and Linux only. [Caddy](https://caddyserver.com) 2.7 or later (on macOS `hallway setup` installs it with Homebrew; on Linux, put a release binary from caddyserver.com on your `PATH`), and one guided sudo step. On Linux also systemd, `nft` and a system CA store. See [Setup](/networking/setup-and-doctor/). | | **For custom domains** v1 | Optional. dnsmasq (Homebrew's on macOS, which setup installs; on Linux the `dnsmasq-base` package or similar) and, on Linux, `systemd-resolved`. | | **For screenshots** v2 | Optional. About 300 MB of disk: `hallway thumbs enable` downloads a headless browser into the data home. See [Thumbnails](/concepts/thumbnails/). | ## Why port 4255? The default dashboard port is **4255**, which spells **HALL** on a phone keypad, the same trick as Local Review's 5622 (LOCA). It stays clear of common dev-server defaults (Angular 4200, Astro 4321, Jekyll and Phoenix 4000), of macOS's AirPlay Receiver on 5000 and 7000, and of the range Hallway assigns to new apps (5100–5999). Override it with `port:` in `config.yaml`, or for one run with `--port ` or `$PORT`. ## Where things go Everything Hallway knows lives in one directory, the **data home**: `~/.hallway`, or wherever `$HALLWAY_HOME` points. See [The data home](/reference/data-home/). Nothing is written outside it except the service definitions (launchd plists, systemd user units, or on Windows scheduled tasks and their files in `%LOCALAPPDATA%\hallway\tasks\`) for the daemon and any services you ask for, and, only during `hallway setup`, the files listed in [Setup, teardown and doctor](/networking/setup-and-doctor/#what-it-changes-outside-the-data-home). --- # Quickstart > The first five minutes with Hallway. Everything on this page works in the current release, on macOS and Linux, with no sudo, and on Windows (best effort) with no administrator rights. For the details of installing (Node version, lingering on Linux, the scheduled task on Windows), see [Installation](/getting-started/installation/). ## 1. Start it ```sh npm i -g hallway hallway service install # run the daemon at every login hallway open ``` Only trying it out? `npx hallway` runs the dashboard in the foreground, until you close the terminal. Install it as above to keep it: Hallway won't install a service from npx's cache, which npm can delete at any time. The dashboard opens at `http://localhost:4255`. The page title is your machine's name. ## 2. Meet what is already running You probably have servers running already. Within a couple of minutes the scanner lists them under **Found running — not yet named**: ```text :4666 dashboard · ~/Code/dashboard-wt-b @ feature/new-chart · up 9 days · orphaned [Claim] [Ignore ▾] ``` For each one Hallway shows what it could work out: the project directory (from the process's working directory, because command lines like `node dist/index.js` say nothing), the git branch, how long it has been up, the page ``, and **orphaned** if the session that started it has gone. Noise is filtered out: IDE and agent-tool helpers on high ephemeral ports, databases, and system daemons never appear. See [Apps](/concepts/apps/#observed-listeners-the-scanner). **Claim** the ones you care about. Claiming writes a small file, `~/.hallway/apps/<slug>.yaml`, on the port the app already uses (Hallway [never changes an app's port](/guarantees/ports/)), and the row becomes a card in **Apps**. **Ignore** the rest, by port, by process name or by directory. The same from a terminal: ```sh hallway scan # what is listening, with identity hallway claim 4666 --slug dashboard hallway ignore 631 --by port ``` ## 3. Add an app by hand Any server you know about can be declared with one command or one file: ```sh hallway app add slides --port 4321 --name "Slides" ``` or create `~/.hallway/apps/slides.yaml` in your editor: ```yaml name: Slides port: 4321 ``` An open dashboard shows the new card within a second, without a reload. Edit or delete the file and the page follows. If you break the YAML, that one card shows the file, line and error; everything else keeps working, and Hallway never rewrites a file you broke. ## 4. Say which apps should be up Each app says what you expect of it. Something that should always be up is `running`; if it stops answering it turns **down**, the only alarming state. Something you only run sometimes is `adhoc` (claimed apps start that way), and when it is off it shows as a calm, closed door instead: ```yaml expected: running # running | adhoc | stopped ``` See [Health states](/concepts/health/). ## 5. Hang up some bookmarks Create `~/.hallway/bookmarks/production.yaml`: ```yaml title: Production links: - title: Admin console url: https://admin.example.com - title: Status page url: https://status.example.com ``` They appear as favicon chips under **Bookmarks**. See [Bookmarks](/concepts/bookmarks/). ## 6. Tell your agents Add a short paragraph to your `AGENTS.md` or `CLAUDE.md` so agents stop leaving orphans behind: ```sh hallway guide --snippet ``` See [Working with agents](/agents/overview/). ## Next - [hallway run](/agents/hallway-run/): start a dev server that outlives the session, on a stable port. - [Services and reboots](/concepts/services/): keep apps running across restarts, and see what the last reboot took down. - [Fronted mode](/networking/fronted-mode/): `https://slides.localhost` instead of a port, after one `hallway setup` (macOS and Linux only). - [Thumbnails](/concepts/thumbnails/): screenshots of your apps on their doors, or generated cards. - The [MCP server](/agents/mcp/) and the [dev-server hook](/agents/hook/), for coding agents. - The [CLI reference](/reference/cli/) and the [file formats](/reference/app-yaml/). --- # Apps: declared, discovered, observed > Where every app on the dashboard comes from, how the scanner identifies unknown servers, and claiming. An **app** is a local HTTP server Hallway knows about, identified by a **slug** such as `slides` or `data-api` (lowercase letters, digits and hyphens; it becomes a hostname in [fronted mode](/concepts/modes/)). Every app comes from exactly one of three sources, and the dashboard always shows which: | Source | Where it comes from | Durable? | Editable in Hallway? | | --- | --- | --- | --- | | **Declared** <span class="ms ms--v0">v0</span> | A file, `apps/<slug>.yaml`, in the data home, written by you, your editor, an agent, the CLI, the API or MCP | Yes | Yes | | **Discovered** (no adapter ships yet) | An adapter that reads another tool's registry | As durable as that registry | No, but it can be **adopted** into a declared app | | **Observed** <span class="ms ms--v0">v0</span> | The port scanner: something is listening, and it is neither declared nor discovered | Remembered in `state/observed.json` | No, but it can be **claimed** or **ignored** | When an observed listener's port matches a declared or discovered app, it is not shown twice: it *enriches* that app with live detail (process ID, working directory, bind address, start time). ## Declared apps The durable kind. The smallest possible declaration is a name and a port: ```yaml # ~/.hallway/apps/slides.yaml name: Slides port: 4321 ``` Everything else is optional: a description, what you expect of it (`expected`), a health-check path, deep links, tags, an icon, proxy options, and a `service:` block if Hallway should keep it running. The full format is in [apps/<slug>.yaml](/reference/app-yaml/). A declared app can describe a server Hallway does *not* manage. If something already runs under its own launchd job, declare it without a `service:` block and Hallway will front and monitor it without creating a second job: ```yaml # apps/notes.yaml: already runs as its own launchd service name: Notes description: A notes app that manages its own service port: 7233 expected: running ``` ## Discovered apps Some tools keep their own registry of servers and ports. A **discovery adapter** would read one, read-only, so those apps appear without being declared twice, switching itself on only when its tool is present on the machine. **Adopt** would turn a discovered app into a declared one you own. **Not built yet.** The daemon has the place for adapters to plug in, and the API and dashboard already know the `discovered` source, but no adapter ships in the current release. Candidates: the port registry of `pm`, the dev-server wrapper in the author's other project [Paddock](https://github.com/edspencer/paddock), plain PM2, Docker's published ports, and process-compose. Until then, every app on the dashboard is declared or observed. ## Observed listeners: the scanner This is the feature that sells the project: *"Something has been listening on :4666 for nine days and you have never named it. What is it?"* Every two minutes (and on demand, with `hallway scan`), the scanner: 1. **Lists listening TCP sockets** with `lsof` on macOS, `ss` (falling back to `/proc`) on Linux, or `netstat` and `tasklist` on Windows. Without root it sees only your own processes, which is the intended scope. Windows doesn't expose a process's working directory, so there Hallway reads it from the process's own memory with one PowerShell call per scan, which needs no administrator rights for your own processes (elevated, other users' and 32-bit processes are out of reach). On any OS, a listener whose directory can't be read shows its command line in the observed tray instead, with a tooltip saying why. 2. **Filters out the noise.** On a typical development machine, IDE language servers and agent-tool helpers outnumber real dev servers about ten to one. Hallway skips ports below 1024, the ephemeral range 49152–65535 (where those helpers live), known non-web daemons (`postgres`, `redis-server`, `sshd` and the like), itself, Caddy, anything in `ignore.yaml`, and ports that belong to declared or discovered apps. 3. **Sniffs for HTTP.** It sends `GET /`. Anything that answers in HTTP, with any status, is a web app; Hallway keeps its status, `<title>` and favicon link. Non-HTTP listeners go into a collapsed "other listeners" group. 4. **Identifies it.** Command lines are often useless (`node dist/index.js`), so **the working directory is the primary clue**. From it Hallway walks up to the project root (the nearest `package.json`, `pyproject.toml`, `Cargo.toml`, `go.mod` or `.git`), takes a name and description from `package.json` or the README, reads the git branch and last commit, guesses the framework, notes the bind address, and checks the parent process: a parent of PID 1 (on Windows, a parent that is no longer running) with no service manager behind it means the session that started it is gone, so it is flagged **orphaned**. A process that launchd or systemd runs (a LaunchAgent, a `brew services` job, a systemd service) has PID 1 as its parent too, but it isn't orphaned: Hallway shows what supervises it instead, as `launchd: com.example.api` or `systemd: api.service`. Some directories say nothing about what runs in them: `/`, your home directory, a package manager's prefix such as `/opt/homebrew` (itself a git repository), or a system directory. A server started in one has no project, so Hallway names it by its supervisor's label or its executable (`api`, `my-server`), with the page's `<title>` alongside. 5. **Labels worktree siblings.** Several copies of one app running from different git worktrees show as `repo @ branch`, so they are no longer indistinguishable. 6. **Remembers.** Each observation is saved under a stable ID (a hash of project root, port and normalised command, since PIDs change on every restart), with when it was first and last seen. That last step matters more than it looks. Once a process dies, its working directory can't be recovered. Recording it *while it is alive* is the only way Hallway can later tell you what was [lost at the last reboot](/concepts/services/#lost-at-last-reboot). ### Claim **Claim** (a button in the dashboard, `hallway claim`, the API, or MCP) writes `apps/<slug>.yaml` from the observation: - the slug, suggested from the package name; - `port`: the port it is already on. **Never a new one** (see [Your ports stay put](/guarantees/ports/)); - `expected: adhoc`; - `project.dir`, from the observed working directory; - the command that started it, as a **commented-out** `service:` block you can switch on later. ### Ignore **Ignore** hides a listener for good, by port, by process name or by directory (you choose), by adding it to [`ignore.yaml`](/reference/bookmarks-yaml/#ignoreyaml). ### Exposed A listener marked **exposed** is listening on all network interfaces (`*:4321` or `0.0.0.0:4321`), not just on this machine, so other devices on your network can reach it. That is sometimes what you want (testing on a phone), but a dev server usually doesn't need it. If it isn't intended, bind it to `127.0.0.1`: most dev servers take a `--host` option or a `HOST` variable. Hover over or focus the badge for the address it's bound to; the detail drawer shows it too. ### Unfamiliar servers Sometimes the scanner finds something you don't recognise. To find out what it is, from a terminal (replace `4321` and `12345` with the port and the PID): ```sh lsof -nP -iTCP:4321 -sTCP:LISTEN # which process is listening, and its PID ps -o pid,ppid,command -p 12345 # its full command line and its parent lsof -a -p 12345 -d cwd # the directory it was started in launchctl list | grep -i <name> # macOS: is it a launchd job (a LaunchAgent, `brew services`)? systemctl status 12345 # Linux: the systemd unit it belongs to, if any ``` On macOS, `brew services list` shows the jobs Homebrew manages, and `~/Library/LaunchAgents` holds the definitions of your own launchd jobs. A label there usually names the program that installed it. If it's something you want to keep an eye on, claim it; if not, ignore it. --- # Health states > The five states an app can be in, how probes decide, and why "stopped on purpose" is never red. Every app has one current state <span class="ms ms--v0">v0</span>. In the dashboard each state is shown with a colour, a shape **and** a word, never colour alone, and in the [hallway imagery](/why-the-name/#the-hallway) as a door. | State | Meaning | Door | Tone | | --- | --- | --- | --- | | <span class="state state--healthy">● healthy</span> | Responding as expected | Ajar, a sliver of warm light showing | Calm, positive | | <span class="state state--unhealthy">▲ unhealthy</span> | Listening but failing: HTTP 5xx, the health path not answering as expected, or a timeout | Light flickering behind it | Warning | | <span class="state state--down">■ down</span> | Should be running, and is not listening | Dark, boarded up | **Alarm: the only red state** | | <span class="state state--stopped">○ stopped</span> | Not listening, and that is fine | Closed | Neutral, quiet | | <span class="state state--unknown">◌ unknown</span> | Not probed yet | In shadow | Neutral | A [managed service](/concepts/services/) that has just been started shows a short-lived <span class="state state--starting">◐ starting</span> for a grace period (30 seconds by default) before "not listening" counts as down. ## Stopped on purpose is not down The key rule. A slide deck you are not using today must not glow red. Only apps you said should be running can be `down`; everything else that isn't listening is calmly `stopped`. What you expect of an app is its `expected` field: | `expected` | When it isn't listening | | --- | --- | | `running` | It should always be up: **down** | | `adhoc` | Up sometimes: **stopped** | | `stopped` | Intentionally off: **stopped** | A managed service you stopped yourself is `stopped` too. Uptime percentages **exclude stopped time**: being off on purpose is not downtime. ## How a probe decides Every 60 seconds (every 15 while a dashboard is open, so the page feels live), with a little jitter and a limit on how many run at once, each declared and discovered app is probed: 1. **TCP connect** to its host and port, with a one-second timeout. No answer means *not listening*: `down`, `stopped` or `starting`, per the tables above. 2. If the app has a `health.path`, Hallway sends `GET <path>`. The expected status (by default any 2xx) means `healthy`; anything else means `unhealthy`. 3. Otherwise it sends `GET /`. Anything below 500 is `healthy`. **Redirects, 401 and 403 count as healthy**: an app behind a login page is up. A 5xx, or no response within five seconds, is `unhealthy`. Probes don't follow redirects, use `GET` rather than `HEAD` (some dev servers mishandle `HEAD`), measure latency to the response headers, send `User-Agent: hallway/<version>`, and send the upstream's own `Host` so apps that check it answer normally. **Flap damping:** an app needs two failed probes in a row to leave `healthy`, and one success to come back. ## Uptime history Each probe is recorded: the state, the HTTP status and the latency. - <span class="ms ms--v0">v0</span> The last **24 hours** at full resolution, drawn on every card as 48 bars of 30 minutes each. - <span class="ms ms--v0">v0</span> **7-day and 90-day** history from hourly rollups (with p50/p95 latency and a latency chart), in the app's detail drawer. The 90-day view reads only the rollups, so it stays quick: a few milliseconds per app with 30 apps and 90 days of history. Periods when Hallway itself was not running are drawn as **no data**, an empty outline, never as down. ## Your tab shows trouble <span class="ms ms--v0">v0</span> The dashboard's tab title counts problems, so a pinned tab works as a passive monitor without any notification system: ```text Hallway everything fine (1 down) Hallway (2 down · 1 unhealthy) Hallway ``` The tab's favicon gets a small status mark too. Only `down` and `unhealthy` count; `stopped` never does. Both update live, so a background tab changes the moment an app falls over. --- # Direct and fronted mode > Linking to localhost ports (direct mode, the default) versus memorable HTTPS hostnames through Caddy (fronted mode). Hallway can link to your apps in two ways. The dashboard works in both and renders the right links for the mode you're in. | | **Direct mode** <span class="ms ms--v0">v0</span>, the default | **Fronted mode** <span class="ms ms--shipped">v1</span>, optional | | --- | --- | --- | | App URLs | `http://localhost:<port>` | `https://<slug>.<base-domain>` | | Dashboard | `http://localhost:4255` | `https://hallway.localhost` (or your custom domain's apex), and still `http://localhost:4255` | | Needs | Nothing | Caddy 2.7 or later; a one-time certificate trust; for custom domains, dnsmasq | | Sudo | Never | Once, during [`hallway setup`](/networking/setup-and-doctor/) (and again to undo it) | | Platforms | macOS, Linux and Windows (best effort) | macOS and Linux only | ## Direct mode No proxy, no DNS, no certificates. Each card links straight to `http://localhost:<port>`. This is where everyone starts, and it is not a degraded fallback: one page that lists everything running, with its health and a working link, is the main thing Hallway is for. ## Fronted mode [Caddy](https://caddyserver.com), running as you, sits in front of your apps and serves each one at a memorable HTTPS hostname. A fixed redirect sends loopback ports 443 and 80 to Caddy's own ports, 4443 and 4480: ```mermaid flowchart TB B["Browser"] -- "https://slides.localhost" --> R["127.0.0.1:443, redirected"] R --> C["Caddy (as you) on 127.0.0.1:4443"] C -- ":4321" --> S["Slides"] C -- ":7333" --> L["Log viewer"] C -- ":4255" --> H["Hallway daemon"] H -. "routes, via the admin socket" .-> C ``` - The default base domain is **`localhost`**: apps live at `<slug>.localhost`. Browsers and macOS resolve any `*.localhost` name to your own machine with no configuration at all, and the name is reserved forever, so there is no DNS server to run. - **HTTPS is required.** Caddy issues certificates from its own local certificate authority, kept in the data home, which `hallway setup` trusts once. - **Adding an app never needs sudo.** The unprivileged daemon pushes routes to Caddy's admin API whenever an app file changes. - Hallway's daemon **never proxies app traffic itself**; Caddy does, including WebSockets for hot reload. - Cards show the hostname, with the port beside it as a direct link that skips the proxy. Want your own namespace instead, like `https://slides.myname.internal`? See [Custom domains](/networking/custom-domains/). The details of Caddy, certificates and Host-header rewriting are in [Fronted mode and HTTPS](/networking/fronted-mode/). ## Why HTTPS, even locally? `http://localhost:<port>` counts as a **secure context** in browsers. `http://app.myname.internal` does not. Without TLS, moving an app behind a hostname would silently break `crypto.subtle`, service workers, `getUserMedia`, the async clipboard API and everything else gated on secure contexts. HTTPS everywhere removes the question. --- # Services and reboots > Keeping apps alive with native launchd jobs, systemd user units or Windows scheduled tasks, the PATH trap, log rotation, and "lost at last reboot". Hallway keeps the apps you care about running with the OS's own service manager: **launchd jobs** on macOS, the mechanism macOS uses for its own background services, **systemd user units** on Linux, and, best effort, **per-user scheduled tasks** on Windows ([below](#windows)). There is no PM2 and no second process manager. ## Hallway itself <span class="ms ms--v0">v0</span> Hallway runs itself the same way, as a user LaunchAgent on macOS, a systemd user unit on Linux, or a scheduled task on Windows: ```sh hallway service install # loaded now and at every login hallway service status hallway service restart hallway service uninstall ``` On macOS the job is `~/Library/LaunchAgents/hallway.daemon.plist`; on Linux it is `~/.config/systemd/user/hallway-daemon.service`; on Windows it is the scheduled task `\hallway.daemon` (see [Windows](#windows)). All start at **login**, not at boot, which is the right behaviour for a laptop. On Linux, enabling lingering once (`loginctl enable-linger $USER`) keeps Hallway's units running without a login session and starts them at boot; Hallway never enables it itself, and `hallway doctor` warns while it is off. Installing, starting, stopping and restarting these jobs have run for real against launchd, systemd and Windows' Task Scheduler in CI. Coming back after a logout or a reboot has not been tested yet, on any OS. ## Managed services <span class="ms ms--shipped">v1</span> An app with a `service:` block is a **managed service**: Hallway generates a job for it, `~/Library/LaunchAgents/hallway.app.<slug>.plist` on macOS, `~/.config/systemd/user/hallway-app-<slug>.service` on Linux, or the scheduled task `\hallway.app.<slug>` on Windows, and keeps it alive. ```yaml # apps/slides.yaml name: Slides port: 4321 expected: running service: command: ["npx", "astro", "dev", "--port", "4321"] cwd: ~/Code/slides ``` ```sh hallway service add slides # generate and load the job: starts at login, restarted if it exits hallway stop slides # stop and disable it, so it stays stopped across logins; expected → stopped hallway start slides hallway restart slides hallway logs slides --follow hallway service rm slides # stop managing it; the app stays declared ``` Every Hallway job has a `hallway.` label (`hallway.daemon`, `hallway.app.<slug>`, and in [fronted mode](/networking/fronted-mode/) `hallway.caddy` and, for a custom domain, `hallway.dnsmasq`); on Linux the units are named the same way with hyphens (`hallway-daemon.service`, `hallway-app-<slug>.service`). So they are easy to find, audit and remove, and can't be confused with jobs you made yourself. Starting a server for the first time is usually done with [`hallway run`](/agents/hallway-run/), which declares the app and creates its job in one step. ### Ports and environment Each managed service gets `PORT` set to its port and `HOST=127.0.0.1` (plus `HOSTNAME`, for frameworks that read that). `$PORT` and `$HOST` in the command's arguments are replaced with the app's values when the job is generated. Frameworks that ignore `PORT` need a flag: | Framework | Flags | | --- | --- | | Vite | `--port $PORT --strictPort` | | Astro | `--port $PORT` | | Next.js | `-p $PORT` | In a YAML `command:` list, `$PORT` needs no quoting; on the `hallway run` command line, quote it (`'$PORT'`) so your shell doesn't expand it first. `--strictPort` matters for Vite: without it, Vite quietly picks another port when its port is busy, and the app "moves" away from the address everything else expects. ### Windows <span class="ms ms--shipped">v1</span> Since 0.5.0, best effort. Windows has no user-level launchd or systemd, so the daemon and each managed app are a **scheduled task** in Task Scheduler's root folder, named by its label (`\hallway.daemon`, `\hallway.app.<slug>`). Each is registered as you, with no administrator rights and no stored password, and starts when you **log on**. Task Scheduler restarts a task only when it fails to *start*, not when its program exits, so the task runs a small supervisor, `hallway supervise` (internal, not in `--help`), inside a headless console so no window opens. The supervisor runs the command in its directory, appends its output to the usual log files, starts it again 10 seconds after it exits, and records its state for `hallway service status`. The task's files are in `%LOCALAPPDATA%\hallway\tasks\`. `hallway stop` disables the task as well as ending it, so it stays stopped at the next logon, as on the other OSes. - **Commands are looked up as cmd.exe does** (`PATHEXT`), so `vite` finds npm's `vite.cmd`, never the `#!/bin/sh` script npm puts beside it. - **Nothing runs while you're logged off.** That would need your password stored with the task, or a service account and administrator rights; Hallway asks for neither, so lingering has no Windows equivalent. - **Checked on GitHub's Windows runners** (install, `hallway run`, stop, start, restart, `--persist` surviving a killed process, log rotation, doctor and removal), **not yet on a real Windows desktop**: a real logon starting the tasks, a logoff stopping them, no console window flashing, and a standard (non-administrator) user registering them are all still unverified. ### The PATH trap launchd jobs and systemd units inherit no shell environment, so a job's `PATH` has to be written into it. Copying your current shell's `PATH` is a latent bug: some Node version managers put **per-shell directories** on it. fnm, for example, adds a `fnm_multishells/<pid>_<timestamp>/bin` symlink created for one terminal session. Copy that into a job and the service's only route to `node`, `npm` and `npx` is a leftover from a shell that closed long ago. Clean it up and every `#!/usr/bin/env node` tool the service runs breaks, while the service itself keeps running. So Hallway's job generator: - resolves the command and the Node binary to **absolute, real paths** in the version manager's stable install directory, never a shim; - puts **that Node's own `bin` directory first** on `PATH`, so `node`, `npm` and `npx` always resolve, to the same Node that runs the service; - builds the rest of `PATH` from stable directories only, dropping per-shell entries and anything that doesn't exist; - records the Node version, so `hallway doctor` can warn when it has been uninstalled, and `hallway service regenerate` can fix it. ### Logs Each service writes to `~/.hallway/logs/<slug>.out.log` and `.err.log`. None of launchd, systemd or Task Scheduler rotates those files, and because they keep them open, renaming doesn't work either, so the daemon copies and truncates them in place once they pass a size limit (10 MB by default), keeping three generations. ## Lost at last reboot <span class="ms ms--shipped">v1</span> When Hallway starts, it compares the machine's boot time with the last one it saw. If the machine has rebooted, every app or listener that was alive before the reboot and is not listening now goes into a notice at the top of the dashboard: **the doors that closed while you were away**. ```text ⚠ Lost at last reboot (Mon 28 Sep 20:05): 6 apps [Review] [Dismiss] ``` **Review** lists each one: what it was, where it ran from (`~/Code/my-app @ main`) and the command that started it, with three actions: - **Restart once** starts it again the way [`hallway run`](/agents/hallway-run/) does: the operating system's service manager owns it, on the port it had. It won't come back after another reboot. - **Make it a service** does the same, and also starts it at login and restarts it if it exits. - **Dismiss** takes it off the list (the banner's own Dismiss clears the whole notice). Something that wasn't an app yet becomes one when you start it (`apps/<slug>.yaml`, on its own port, with the command and directory Hallway recorded). Anything that comes back, by one of these actions or any other way, is marked **back** until you dismiss it. "Alive before the reboot" means seen in Hallway's last scans before it: a server you stopped a day earlier isn't listed. The notice survives Hallway restarting, until you dismiss it. From a terminal: ```sh hallway lost # what was lost, with ids, directories and commands hallway lost restart <id|port> # start one again, once hallway lost service <id|port> # start it again, as a service hallway lost dismiss <id|port> # or --all ``` Services that start at login get their start-up grace period (`services.startGraceSeconds`, 30 seconds by default) before anything is called lost, so a slow starter isn't listed by mistake. A server started by one of these actions shows as <span class="state state--starting">◐ starting</span> while it comes up. The notice is kept in `state/lost.json` and is also in `/api/state` and [`GET /api/lost`](/reference/http-api/), in `llms.txt`, and in the [MCP server](/agents/mcp/)'s `get_status`; MCP can read it but can't restart anything. :::note[Checked by simulation] "Lost at last reboot" has been checked on Linux with a simulated reboot (the recorded boot time moved back, the servers killed, the daemon restarted). It has not yet been seen after a real reboot, on Linux or on a Mac. ::: This only works because Hallway [records what it observes](/concepts/apps/#observed-listeners-the-scanner) while the processes are alive. Once a process is gone, its working directory can't be recovered by any means, so a tool that starts looking after the reboot is already too late. --- # Bookmarks > Groups of links to the remote things you always forget the URL of, hung by the front door. <span class="ms ms--v0">v0</span> Bookmarks are links to anything, usually remote: production admin consoles, BI dashboards, status pages, documentation portals. They make the dashboard a jumping-off page, the role a home-lab dashboard plays on a home network. In the hallway they hang **on a key hook by the front door**: small favicon chips in labelled groups, lighter than the app cards because there is nothing behind them to check. ## One file per group Each group is a file, `~/.hallway/bookmarks/<group-slug>.yaml`: ```yaml title: Production order: 10 # groups sort by order, then title collapsed: false links: - title: Admin console url: https://admin.example.com description: Production admin # shown as a tooltip icon: auto # fetch the favicon once and cache it tags: [prod] - title: BI dashboards url: https://bi.example.com ``` Full format: [Bookmarks and ignore files](/reference/bookmarks-yaml/). From the CLI: ```sh hallway bookmark add production https://status.example.com --title "Status page" hallway bookmark ls hallway bookmark rm production https://status.example.com hallway bookmark edit production # opens the YAML in $EDITOR ``` ## How they behave - **Inert.** Nothing is launched, and remote bookmarks are not health-checked (remote checks would leak traffic and mostly hit login walls). Opt-in checks per link are an idea for later. - **Favicon-led.** Each link's favicon is fetched once and cached. Without one, it gets a monogram on a colour derived from the hostname. You pattern-match on logos here, not status. - **Collapsible.** A group's collapsed state is saved in its file, so it is the same in every browser. - **Files are the truth.** You can add and edit links inline in the dashboard, but that only writes the YAML. - **Yours.** Bookmarks are user-specific and never baked into Hallway. The data home can live in a dotfiles repo if you want them on several machines. --- # The dashboard > The home screen, app cards, the observed tray, keyboard shortcuts and the hallway design. The dashboard is meant to be **good enough to set as your browser's home page**. That is a requirement, not polish. This page describes the design the dashboard follows (<span class="ms ms--v0">v0</span>, with later additions marked); the sketches are drawn in text from that design, not screenshots. ## Principles 1. **A jumping-off point, not a control panel.** The main action is *go to the thing*. 2. **Instant.** Everything is local, so the whole page paints from one request in well under 200 ms. Live updates arrive over Server-Sent Events; there are no spinners on the main view. 3. **Calm.** Most of the time everything is fine or intentionally off, so the page is quiet. Only `down` is alarming. 4. **Dense but legible.** Many apps visible at once on a laptop screen. 5. **Keyboard-first.** It must be faster than remembering a URL. 6. **Never needs hand-maintenance** to stay accurate. ## Layout ```text ┌──────────────────────────────────────────────────────────────────────────────┐ │ HALLWAY · my-laptop [ / filter… ] ● 9 up · 1 down │ ├──────────────────────────────────────────────────────────────────────────────┤ │ ⚠ Lost at last reboot (Mon 28 Sep 20:05): 6 apps [Review] [Dismiss] │ ├──────────────────────────────────────────────────────────────────────────────┤ │ APPS │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ [tile] │ │ [tile] │ │ [tile] │ │ [tile] │ │ │ │ Slides │ │ Log viewer │ │ Docs site │ │ dash @ wip │ │ │ │ :4321 │ │ :7333 │ │ :4455 │ │ :4670 │ │ │ │ ▮▮▮▮▮▮▮▮▮▮ │ │ ▮▮▮▮▮▮▮▯▮▮ │ │ ░░░░░░░░░░ │ │ ▮▮▮▮▮ │ │ │ │ ● 99.9% 12ms │ │ ● 98.1% 40ms │ │ ○ stopped │ │ ● adhoc │ │ │ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │ ├──────────────────────────────────────────────────────────────────────────────┤ │ FOUND RUNNING — NOT YET NAMED │ │ :4666 dashboard · ~/Code/dashboard-wt-b @ feature/new-chart · up 9 days │ │ orphaned [Claim] [Ignore ▾] │ ├──────────────────────────────────────────────────────────────────────────────┤ │ BOOKMARKS │ │ Production (◉ Admin console) (◉ BI dashboards) (◉ Status page) │ │ Docs (◉ API reference) (◉ Runbooks) │ └──────────────────────────────────────────────────────────────────────────────┘ ``` Which sections appear, and in what order, is `dashboard.sections` in [`config.yaml`](/reference/config-yaml/). The observed tray hides itself when empty, and so do bookmarks. ## App cards Top to bottom: - **A thumbnail**: the app's favicon on a tile of its own dominant colour, or a monogram when it has none. <span class="ms ms--shipped">v2</span> Opt in to real screenshots (`hallway thumbs enable`) and each healthy app's door gets a pane of glass showing the app itself, re-shot when the app restarts and every six hours. Or set `thumbnails.mode: card` and every door is drawn as a generated card with its name, address, framework and `repo @ branch`. See [Thumbnails](/concepts/thumbnails/). - **The name**, and for worktree siblings `repo @ branch` in smaller type. - **The address**, the main click target: `localhost:4321` in direct mode, `slides.localhost` in [fronted mode](/concepts/modes/) (with the port beside it as a direct link that skips the proxy), with a copy button on hover. - **The uptime bar** for the last 24 hours, with the uptime percentage and median latency. - **The state**, as colour, shape and word: <span class="state state--healthy">● healthy</span>, <span class="state state--unhealthy">▲ unhealthy</span>, <span class="state state--down">■ down</span>, <span class="state state--stopped">○ stopped</span>, <span class="state state--unknown">◌ unknown</span>. - **Badges, only when relevant**: `orphaned` (the session that started it has gone and no service manager runs it), `exposed` (bound to every interface, so reachable from your network; [more](/concepts/apps/#exposed)), `managed` (a launchd, systemd or scheduled-task service), its source, or a config error. Hover over or focus `orphaned` or `exposed` for what it means. Click or Enter opens the app; ⌘/Ctrl-click opens a new tab. A secondary button opens the **detail drawer**: full identity (directory, git branch and last commit, framework, PID, start time, the command that started it), uptime and latency history over 24 hours, 7 days or 90 days, a larger screenshot when there is one, the YAML file with an "open in editor" link, deep links and any config error. Start, stop and restart buttons and a live log tail for managed services are not built yet: use `hallway stop`, `start`, `restart` and `logs`. After a reboot, a **Lost at last reboot** banner sits above the apps, listing what was running before and didn't come back, with Restart once, Make it a service and Dismiss. See [Services and reboots](/concepts/services/#lost-at-last-reboot). ## Keyboard | Key | Action | | --- | --- | | `/` | Focus the filter (fuzzy over name, slug, host, port, directory, branch and bookmark titles) | | `↑ ↓ ← →` | Move between cards and chips | | `Enter` | Open; `⌘/Ctrl-Enter` opens a new tab | | `c` | Copy the focused item's URL | | `i` | Open the detail drawer | | `Esc` | Clear the filter, or close the drawer | | `?` | Show the shortcuts | Type a filter that matches exactly one thing and press Enter to open it: faster than remembering a URL. ## Errors and empty states - A broken YAML file shows its error **in place**, on the affected card (or as a banner for `config.yaml` and bookmark files), with the file, line and message. Never a blank page. - With no apps declared yet, the page leads with the observed tray: *"We found 4 web servers running. Name the ones you care about."* - With nothing running and nothing declared, it shows the two commands that matter and a link to the agent guide. ## The hallway The visual design is a **calm, well-lit hallway**. Each app is a numbered **door**; its port is the brass **door number**; its state shows in the door (ajar with lamplight for healthy, closed for stopped, dark for down). Unclaimed listeners are noises behind doors you didn't know were there; bookmarks hang by the front door. The theme is dark-first, a warm corridor at night with lamplight amber, with a daylit light theme. The imagery always sits *on top of* plain labels: "Claim", "Ignore" and "Down" stay as they are, and nobody has to decode a pun to read a state. [Why the name](/why-the-name/) tells where it comes from. :::note[Working direction] The hallway identity is approved as the working direction, not as final branding. The logo on this site is a placeholder. ::: --- # Thumbnails > The picture on each door: a favicon by default, opt-in screenshots (hallway thumbs enable), or generated cards. Every app's door on the [dashboard](/concepts/dashboard/) carries a picture. Which kind is `thumbnails.mode` in [`config.yaml`](/reference/config-yaml/): | `thumbnails.mode` | The door shows | | --- | --- | | `favicon` (the default) | The app's favicon on a small tile; a monogram on a colour derived from the slug if it has none. | | `screenshot` | A screenshot of the app as the door's pane of glass. An app with no screenshot yet falls back to its favicon. | | `card` | Every door drawn as a generated card: the app's name, address, framework and `repo @ branch`. | ## Favicons <span class="ms ms--v0">v0</span> The default needs nothing. Hallway fetches each app's favicon (and each bookmark's), caches it in the data home (`cache/favicons/`), and paints the door with the icon's dominant colour. ## Screenshots <span class="ms ms--shipped">v2</span> Screenshots are opt-in, because they need a browser: ```sh hallway thumbs enable # install the browser into the data home and turn screenshots on hallway thumbs refresh # take them again now (or: hallway thumbs refresh <slug>) hallway thumbs status # the mode, whether a browser is installed and its size, how many screenshots hallway thumbs disable # turn them off and delete the browser and the screenshots ``` `hallway thumbs enable` installs Chromium's headless shell (about 300 MB) into `cache/browser/` in the [data home](/reference/data-home/), **never anywhere else**, and sets `thumbnails.mode: screenshot`. Hallway itself doesn't depend on Playwright or any browser: `npm i -g hallway` downloads none. Run `enable` again after an upgrade if Hallway says the browser is out of date; it is safe to repeat. How screenshots behave: - **Healthy apps only.** Declared apps that are `healthy` get one; bookmarks are never shot. - **Kept fresh**: a new screenshot when there is none, when the app has restarted (its process ID changed) or its address changed, and when the last one is six hours old. `hallway thumbs refresh` (which needs the daemon running) takes them now. - **Light**: one page at a time, in one headless browser that closes after two idle minutes. A page that never goes quiet is shot after ten seconds anyway. - **Private**: screenshots are taken on your machine, stored in `cache/thumbs/`, and never leave it. - The [detail drawer](/concepts/dashboard/#app-cards) shows the screenshot larger. `hallway thumbs disable` sets the mode back to `favicon` and deletes `cache/browser/` and `cache/thumbs/`. ## Generated cards <span class="ms ms--shipped">v2</span> With `thumbnails.mode: card`, every door is drawn as a card: the app's name, its address on a brass plate, its framework and `repo @ branch`, on the door's own paint. The daemon renders it with fonts bundled in the package, so it looks the same on every machine, and needs no browser. ```yaml # config.yaml thumbnails: mode: card ``` `card` is a look you choose for every door, not a fallback: in the other modes an app with no favicon keeps its monogram. The drawer doesn't repeat the card, since it already shows everything a card says. ## From the API `GET /api/apps/:slug/thumb` serves an app's picture with an ETag (a card as a 640×400 PNG), and each app in `/api/state` has a `thumbKind` of `favicon`, `screenshot` or `card` saying which it is. `POST /api/thumbs/refresh` takes new screenshots. See the [HTTP API](/reference/http-api/). --- # Fronted mode and HTTPS > How Hallway uses Caddy, running as you, to serve every app at https://<slug>.localhost, and why it rewrites the Host header. <span class="ms ms--shipped">v1</span> In fronted mode, [Caddy](https://caddyserver.com) serves each app at `https://<slug>.<base-domain>`, by default `https://<slug>.localhost`, with a certificate your machine trusts. It is optional: turn it on with [`hallway setup`](/networking/setup-and-doctor/) and off with `hallway teardown`. For the overview, see [Direct and fronted mode](/concepts/modes/). :::note[Not on Windows] Fronted mode is macOS and Linux only. On Windows (best effort) its parts have no user-level equivalent, so `hallway setup` refuses before it looks for or changes anything, `hallway teardown` has nothing to undo, and apps keep their `http://localhost:<port>` addresses. ::: ## Why Caddy Hallway generates Caddy's configuration; it never proxies app traffic itself. Caddy brings, out of the box: - **WebSocket proxying**, transparently. Many apps need it: Vite and Astro hot reload, and apps with live state. - **Automatic TLS from a local certificate authority**, and HTTP/2. - **Live config reloads through an admin API**, without dropping connections. This is what lets the unprivileged daemon add an app without sudo. Rejected alternatives: a Node proxy inside the daemon (WebSocket upgrades, TLS and HTTP/2 all reinvented), Traefik (built around container labels), nginx (manual config, no automatic local TLS) and localias (no wildcard subdomains, no persistence). Hallway needs **Caddy 2.7 or later**. On macOS, `hallway setup` installs it with Homebrew if it is missing; on Linux, put a release binary from [caddyserver.com](https://caddyserver.com/download) on your `PATH` (distribution packages may be older, and may start a system Caddy on port 80), or point `proxy.caddy` in `config.yaml` at it. ## How it fits together - **Caddy runs as you**, as a user-level job (`hallway.caddy`: a LaunchAgent on macOS, a systemd user unit on Linux), listening on loopback only, `127.0.0.1` and `[::1]`, on ports **4443** (HTTPS) and **4480** (HTTP), which need no privileges. Those are `proxy.httpsPort` and `proxy.httpPort` in [`config.yaml`](/reference/config-yaml/). - **A fixed redirect** sends loopback connections to ports 443 and 80 on to 4443 and 4480, so links need no port number. It is a pf anchor on macOS and an nftables table on Linux, installed once by `hallway setup`. - **Its admin API is a private Unix socket** in the data home, `state/caddy/admin.sock`, readable and writable only by you (mode 0600). It never listens on TCP. - **The daemon pushes the whole configuration** to that socket when it starts and whenever an app file or `config.yaml` changes, and first writes it to `~/.hallway/generated/caddy.json`. Caddy starts from that same file, so routes are back after a reboot even before the daemon is. Why it is built this way, and exactly what needs root, is on [Setup, teardown and doctor](/networking/setup-and-doctor/#the-security-model). ## Hostnames - The dashboard: `hallway.localhost` (on a custom domain, its bare apex, e.g. `myname.internal`). It is `domain.dashboard` in `config.yaml`. - Each app: `<slug>.<base>`, plus any extra names in its `proxy.aliases`. - The dashboard's label (`hallway`), `www` and `api` are reserved slugs. - New apps get a hostname as soon as their YAML file exists: no sudo, no restart. ## What a route looks like For each app the daemon emits one route. It sends Caddy JSON; in Caddyfile notation, for readability: ```caddyfile { # Every name gets a certificate from the local CA: needed for .test and # made-up TLDs, harmless for .localhost and .internal. local_certs } slides.localhost { reverse_proxy 127.0.0.1:4321 { header_up Host {upstream_hostport} # rewrite Host by default } } hallway.localhost { reverse_proxy 127.0.0.1:4255 # the dashboard keeps its Host } ``` HTTP on port 80 is redirected to HTTPS by Hallway's own route, which knows the public port. HTTP/3 is off. ## Host-header rewriting Caddy's `reverse_proxy` **preserves the browser's `Host` header** by default. But well-behaved local apps defend against DNS rebinding by **rejecting any `Host` that isn't localhost**: Local Review answers `403 forbidden host`, Vite has `server.allowedHosts`, and other dev servers have similar checks. Fronting them unmodified would break them on day one. So Hallway **rewrites `Host` to the upstream by default**: the app sees `127.0.0.1:4321`, exactly as if you had visited it directly. Caddy still adds `X-Forwarded-Host`, `X-Forwarded-Proto` and `X-Forwarded-For`, so an app that needs its public name can read it. For an app that builds absolute URLs from `Host` and doesn't check it, opt out per app: ```yaml proxy: preserveHost: true ``` The dashboard's own route is the exception: the daemon gets `hallway.localhost` unchanged, and accepts it in fronted mode. **Hot reload works through the proxy** with `Host` rewritten: Vite's HMR was checked with a real hot update and no `server.hmr.clientPort` setting. (Astro runs on Vite but hasn't been checked separately.) ## Certificates - Caddy's local certificate authority lives in the data home (`state/caddy/data/`). `hallway setup` trusts its root certificate itself, with printed commands: in the **System keychain** on macOS (`security add-trusted-cert`), and in the **system CA store** on Linux (`update-ca-certificates` or `update-ca-trust`), plus your own NSS database, which Chrome and Chromium read on Linux, without sudo. - **Firefox** uses its own certificate store: set `security.enterprise_roots.enabled` to `true` in `about:config`, or import the root by hand. `hallway doctor` mentions this. - Caddy's leaf certificates are short-lived and renewed automatically. - `hallway teardown` removes the root from the trust store. The CA itself stays in the data home, unused. :::caution[Not yet checked in a browser on a Mac] On macOS, CI runs setup for real and `curl` accepts the certificate with no extra flags. No browser has been driven on a real Mac yet: whether Safari, Chrome and Firefox (with the setting above) accept it, and whether Safari resolves `*.localhost` addresses typed into its address bar, are still to be verified. On Linux, CI checks the same with `curl` and the system trust store. ::: ## Fronted mode without the redirect If you can't or don't want to install the redirect (in a container, say), set `proxy.publicPort` to the same value as `proxy.httpsPort`: ```yaml # config.yaml proxy: publicPort: 4443 ``` `hallway setup` then installs no redirect, and links carry the port: `https://slides.localhost:4443`. Trusting the certificate still needs sudo once. ## Ports 80 and 443 may already be taken Container tools (Docker Desktop, Rancher Desktop, Lima, Colima and similar) often forward ports 80 and 443 from their VM. `hallway setup` refuses to start while anything else holds 80, 443, or Caddy's own ports, and says which process (name, PID and command line) holds them; turning that tool's forwarding of 80 and 443 off is usually the fix. On macOS, where you can't see root's sockets, it also checks whether 443 answers with Hallway's certificate. `hallway doctor` reports the same. ## When the daemon or an app is down If the daemon isn't running, Caddy serves a static fallback page (`generated/fallback.html`, rewritten by the daemon on every change) at the dashboard's hostname. It lists every app with a working link, so a dead dashboard still answers "what do I have?", and says how to restart it: ```sh hallway service restart ``` When an app isn't answering, its hostname serves a page that says which app it is, where Caddy looked, and how to start it, instead of a bare proxy error. ## App bind addresses Many dev servers started with `--host` listen on `0.0.0.0`, which exposes them to your local network. Behind a proxy they don't need to. Managed services get `HOST=127.0.0.1`; the dashboard marks any app listening on all interfaces as **exposed**, and `hallway doctor` lists them. --- # Custom domains > Serving apps under your own namespace such as myname.internal, and which domains to avoid. <span class="ms ms--shipped">v1</span> The default base domain, `localhost`, needs no DNS at all. If you want your own namespace instead, say `https://slides.myname.internal`, set a custom base domain: ```sh hallway setup --domain myname.internal --print # see the plan first hallway setup --domain myname.internal ``` With a custom base, the dashboard lives at the bare apex (`https://myname.internal`) and apps live underneath it (`https://slides.myname.internal`). Everything in [fronted mode](/networking/fronted-mode/) applies; a custom domain only adds a way to resolve the names. ## Choosing a domain **Use a name under `.internal`.** ICANN reserved `.internal` for private use in 2024, so it will never be delegated on the public internet. The domain needs at least two labels (`myname.internal`, not `internal`). | Base domain | Verdict | Why | | --- | --- | --- | | `localhost` | ✅ The default | No DNS, reserved forever (RFC 6761). | | `*.internal` | ✅ Recommended custom choice | Reserved by ICANN for private use; never delegated. | | `*.test` | ✅ Fine | Reserved (RFC 6761). | | `*.home.arpa` | ✅ Fine | Meant for home networks (RFC 8375). Long. | | A made-up TLD, e.g. `name.surname` | ⚠️ Allowed, with a warning | Not reserved: someone could register that TLD later. | | A name under another real TLD, e.g. `dev.example.com` | ⚠️ Allowed, with a warning | Fine if it's a domain you own; otherwise you'd be shadowing someone else's names on this machine. Certificates still come from Hallway's local CA, never a public one. | | `*.local` | ❌ Refused | Reserved for mDNS/Bonjour (RFC 6762). macOS sends `.local` lookups to mDNS, and wildcards don't work. | | `*.dev`, `*.app` and the other TLDs on browsers' HSTS preload list | ❌ Refused | Real, delegated TLDs on which browsers force HTTPS; the old "`.dev` for local development" setups all broke when it was delegated. | | `*.example`, `*.invalid` | ❌ Refused | Reserved for documentation and for names that must never resolve. | Every name gets its certificate from Hallway's local certificate authority, so `.test` and made-up TLDs never go to a public certificate authority. ### About made-up TLDs They work. Nothing stops you resolving names under an undelegated TLD on your own machine, and if that TLD is delegated later, your setup keeps working: your machine's resolver configuration wins, and since fronted mode is always HTTPS with a locally trusted CA, the `.dev`-style HSTS failure doesn't apply. You only lose the ability to reach the real public names. But ICANN's 2026 round of new top-level domains is under way, so `hallway setup` and `hallway doctor` check your TLD against IANA's list of delegated TLDs (cached for a week in the data home) and warn if it has become real. Prefer `.internal`. ## What `hallway setup --domain` does Unlike `*.localhost`, a custom domain needs something to answer DNS for it. Hallway runs its **own** [dnsmasq](https://thekelleys.org.uk/dnsmasq/doc.html), as you, and points your system resolver at it for that one domain: 1. **dnsmasq**, if it is missing: on macOS, setup installs it with Homebrew (`brew install dnsmasq`, as you, no sudo). On Linux, install it yourself (on Debian and Ubuntu the `dnsmasq-base` package, which starts no system service), then run setup again. 2. **Its configuration**, `~/.hallway/generated/dnsmasq.conf`, answers **the apex and every name under it** with your own machine, a wildcard, so adding an app never touches DNS again, and answers nothing else: ```text port=4253 listen-address=127.0.0.1 bind-interfaces no-resolv no-hosts address=/myname.internal/127.0.0.1 address=/myname.internal/::1 ``` `listen-address` and `bind-interfaces` keep it on loopback, so it can't become an open resolver on your network. 3. **It runs as a user-level job**, `hallway.dnsmasq` (a LaunchAgent on macOS, a systemd user unit on Linux), on `127.0.0.1:4253` (`domain.dnsPort` in `config.yaml`). It never needs root, because it doesn't use port 53. 4. **The resolver entry**, the one privileged step a custom domain adds, printed and run through `sudo`: - macOS: `/etc/resolver/myname.internal`, containing `nameserver 127.0.0.1` and `port 4253`. The file is named after the **full** base domain, never just the TLD, so only your own names are captured. It covers the apex as well as subdomains. - Linux: a `systemd-resolved` drop-in, `/etc/systemd/resolved.conf.d/hallway.conf`, routing `~myname.internal` to `127.0.0.1:4253`. Other resolvers aren't supported by setup. 5. **Sets `domain.base`** (and `mode: fronted`) in `config.yaml`. `hallway doctor` then checks dnsmasq is running and bound to loopback only, the resolver entry is in place, and both the apex and a name under it resolve to your machine. By hand: `scutil --dns` and `dscacheutil -q host -a name slides.myname.internal` on macOS, `resolvectl query slides.myname.internal` on Linux. `hallway teardown` removes the resolver entry and the dnsmasq job. Why not `/etc/hosts`, as some tools do? It needs sudo for *every* new app and can't express wildcards. A resolver entry needs sudo once. :::note[Checked on CI, not yet on a desktop Mac] The macOS resolver file with a `port` line has worked on CI's macOS runners, end to end. Homebrew's dnsmasq notes say a resolver override on a port other than 53 doesn't work on current macOS, so it is worth re-checking on a desktop Mac, where mDNSResponder may behave differently. ::: --- # Setup, teardown and doctor > The one command that needs sudo, what it changes and why that is safe, how to undo it, and the diagnostics that check everything. Every step that needs root lives in one command, `hallway setup`, so the daemon never needs privileges. You only need it for [fronted mode](/networking/fronted-mode/): direct mode, the default, needs no setup at all. :::note[Not on Windows] Fronted mode is macOS and Linux only. On Windows (best effort) its parts have no user-level equivalent, so `hallway setup` refuses before it looks for or changes anything, `hallway teardown` has nothing to undo, and apps keep their `http://localhost:<port>` addresses. ::: ## `hallway setup` <span class="ms ms--shipped">v1</span> Run it once, when you want fronted mode: ```sh hallway setup --print # show the full plan; run nothing hallway setup # fronted mode on *.localhost hallway setup --domain myname.internal # under your own domain instead ``` The plan is a numbered list, and each step is tagged with who does it: - **`[Hallway]`**: Hallway's own files and user-level jobs, as you. - **`[you]`**: on macOS, `brew install caddy` (and `dnsmasq` for a custom domain) if they're missing, as you. - **`[sudo]`**: a privileged change. The exact command is shown; a file to be installed as root is first written to `~/.hallway/generated/setup/` as you, shown in full, then copied into place with `sudo install`. For fronted mode on `localhost`, it: 1. installs Caddy if it is missing (macOS, with Homebrew; on Linux it stops and says how to get a release binary); 2. writes Caddy's configuration (`generated/caddy.json`) and the fallback page, and checks them with `caddy validate`; 3. starts **Caddy as you**, as the user-level job `hallway.caddy`, on `127.0.0.1:4443` and `:4480`, with its admin API on a private socket; 4. **[sudo]** trusts Hallway's local certificate authority; 5. **[sudo]** redirects loopback ports 443 and 80 to Caddy's 4443 and 4480; 6. sets `mode: fronted` in `config.yaml`; 7. checks that `https://hallway.localhost` answers through the redirect, with Hallway's certificate. With `--domain`, it also writes and starts Hallway's own dnsmasq (as you) and adds one more `[sudo]` step, the resolver entry for that domain. See [Custom domains](/networking/custom-domains/). The rules it follows: - **Each privileged command is printed with a one-line reason, then run with `sudo`**, so you see exactly what happens; `sudo` asks for your password as usual. - **It is idempotent.** Re-running it checks the real state (the root in the trust store, the installed files' exact contents, a TLS handshake on port 443) and only does what is missing; already-done steps say so. If a command fails, the run stops there, and running setup again carries on. - **It refuses before running anything** when the domain is `.local`, a TLD on browsers' HSTS preload list (`.dev`, `.app`, …), `.example` or `.invalid`; when anything else holds port 443 or 80, or Caddy's or dnsmasq's ports (it names the process, its PID and command line); when a helper it needs is missing and can't be installed; and on Linux, when there is no systemd, no `nft` or no system CA store it recognises. It **warns** about a real or made-up TLD, a Caddy older than 2.7, and another Caddy's admin API on port 2019. ## The security model Serving `https://slides.localhost` means something must answer on `127.0.0.1:443`, which only root may bind. The obvious answer, a Caddy running as root, has a problem: anything that can reach Caddy's admin API can reconfigure it, for instance to serve files only root can read. A root Caddy would turn any malicious install script on your machine into a privilege escalation. So Hallway doesn't run one. - **Caddy never runs as root.** It runs as you, as a LaunchAgent or a systemd user unit, on loopback ports 4443 and 4480, which need no privileges. It can do nothing you couldn't already do. - **Its admin API is a Unix socket in your data home** (`state/caddy/admin.sock`) with mode 0600, never a TCP port. Only your own processes can reach it, and since Caddy holds no privilege, reaching it gains them nothing. - **The only privileged changes are fixed, and read nothing you could rewrite later:** 1. **The redirect** of loopback ports 443 → 4443 and 80 → 4480. On macOS, a pf anchor (`com.apple/hallway`, in `/etc/pf.anchors/hallway`), loaded at boot by the `hallway.pf` LaunchDaemon; macOS's own `/etc/pf.conf` already evaluates `com.apple/*` anchors, so it is never edited. On Linux, an nftables table of Hallway's own (`inet hallway`, in `/etc/hallway/redirect.nft`), applied at boot by `hallway-redirect.service`. 2. **Trusting Hallway's local certificate authority**: in the System keychain on macOS, in the system CA store on Linux (plus your own NSS database, for Chrome, without sudo). 3. **For a custom domain only, the resolver entry**: `/etc/resolver/<base>` on macOS, a `systemd-resolved` drop-in on Linux, pointing that one domain at Hallway's dnsmasq, which also runs as you. Each is printed and run through `sudo`. After that, adding, changing or removing an app never needs sudo again: the daemon reconfigures Caddy through its socket, as you. Rejected along the way: a root Caddy with its admin API on TCP or on a user-only socket (your own processes could still escalate), a root Caddy with no admin API (sudo for every new app), Caddy as you on `0.0.0.0:443` (every app exposed to your network), and on Linux `setcap` on the Caddy binary (whoever reaches its API could bind any privileged port). See [No root in the daemon](/guarantees/no-root/). :::caution[Not yet verified on a real Mac] The pf redirect has run for real on CI's macOS runners, for locally originated connections. Whether it survives another tool reloading `/etc/pf.conf`, a macOS update and a reboot hasn't been checked yet. If it stops working, `hallway doctor` says so and `hallway setup` reinstalls it. ::: ### Without any redirect If you set `proxy.publicPort` to the same value as `proxy.httpsPort` (4443) in `config.yaml`, setup installs no redirect: links then carry the port (`https://slides.localhost:4443`). Trusting the certificate still needs sudo once. See [Fronted mode without the redirect](/networking/fronted-mode/#fronted-mode-without-the-redirect). ### What it changes outside the data home | | macOS | Linux | | --- | --- | --- | | Caddy, as you | `~/Library/LaunchAgents/hallway.caddy.plist` | `~/.config/systemd/user/hallway-caddy.service` | | dnsmasq, as you (custom domain) | `~/Library/LaunchAgents/hallway.dnsmasq.plist` | `~/.config/systemd/user/hallway-dnsmasq.service` | | The redirect (sudo) | `/etc/pf.anchors/hallway`, `/Library/LaunchDaemons/hallway.pf.plist` | `/etc/hallway/redirect.nft`, `/etc/systemd/system/hallway-redirect.service` | | The trusted root (sudo) | the System keychain | the system CA store (e.g. `/usr/local/share/ca-certificates/hallway-local-ca.crt`); your NSS database, as you | | The resolver (sudo, custom domain) | `/etc/resolver/<base>` | `/etc/systemd/resolved.conf.d/hallway.conf` | Caddy's configuration, certificates and state, dnsmasq's configuration, and the files staged for `sudo` all stay in the data home. ## `hallway teardown` <span class="ms ms--shipped">v1</span> Undo it any time: ```sh hallway teardown --print # show what it would remove; run nothing hallway teardown hallway teardown --uninstall-packages # also uninstall Caddy and dnsmasq from Homebrew ``` It removes only what it finds: the resolver entry, the dnsmasq job, the redirect, the trusted root and the Caddy job, with `sudo` for the privileged ones, then sets `mode: direct`. The dashboard keeps working at `http://localhost:4255`. Homebrew packages are left alone unless you ask; on Linux, `--uninstall-packages` only reminds you to use your package manager. The certificate authority stays in the data home, unused, so a later setup can trust the same root again. ## `hallway doctor` ```sh hallway doctor hallway doctor --json ``` Read-only diagnostics. Each check prints pass, warn or fail with a suggested fix. **<span class="ms ms--v0">v0</span> The daemon, your data and your services:** - the daemon is running, its port is free or its own, and its service (launchd job or systemd user unit) is loaded; - on Linux, whether lingering is on, so services keep running without a login session; - the data home is readable, and lists any YAML parse or validation errors; - two declared apps on one port; - apps listening on `0.0.0.0`, which exposes them to your network; - managed-service jobs that are missing or orphaned, whose `PATH` contains per-shell version-manager directories or entries that no longer exist, or whose Node version has been uninstalled (see [the PATH trap](/concepts/services/#the-path-trap)). **<span class="ms ms--shipped">v1</span> Fronted mode**, when it is on: - Caddy: its version, its job, its admin socket answering and private, and the configuration it is running matching `generated/caddy.json`; - who holds Caddy's ports and ports 80 and 443 (process, PID and command line), if it isn't Caddy; - a TLS handshake through the redirect that verifies against Hallway's root, and the HTTP-to-HTTPS redirect; - the root trusted, with a note for Firefox; - `*.localhost` resolving (a warning only: browsers resolve it themselves); - for a custom domain: its TLD (delegated or made up, checked against IANA's list), dnsmasq running and bound to loopback only, the resolver entry in place, and both `<base>` and a name under it resolving to your machine. --- # Files first > How coding agents work with Hallway - plain YAML, live updates, one command to start servers, and MCP. Hallway is built for the servers coding agents spin up. Its agent interface follows the author's sibling project [Local Review](https://local-review.edspencer.net): **files first, MCP second**. ## Plain files are the interface <span class="ms ms--v0">v0</span> Everything Hallway knows lives in small YAML files under `~/.hallway` ([the data home](/reference/data-home/)). An agent that can read and write files can: - **register a server** by writing `apps/<slug>.yaml` (a `name` and a `port` is enough); - **add bookmarks** by editing `bookmarks/<group>.yaml`; - **check what's running** with `hallway status --json` or `GET http://localhost:4255/api/state`. A file watcher turns every edit into a live update, so the dashboard changes within a second, whether the edit came from you, your editor, an agent, the CLI or the API. That works with every agent, needs no configuration, and makes a hand edit behave exactly like an agent's. The CLI, the HTTP API and the MCP server are all thin wrappers over **one write module** that validates and writes those same files atomically. None of them is a separate path, and none is the only way to do something. ## The habit to change The tooling can't fix the real problem alone: agents will keep running `npm run dev` unless told otherwise, and every one of those becomes an orphan when the session ends. So Hallway gives agents: 1. <span class="ms ms--shipped">v1</span> **One command instead of `npm run dev`**: [`hallway run`](/agents/hallway-run/). It hands the server to launchd, systemd or (on Windows) Task Scheduler so it outlives the session, keeps a stable port, prints the URL and exits. 2. <span class="ms ms--v0">v0</span> **A guide and a snippet**: [`hallway guide`](/agents/agent-guide/) explains the rules, and `hallway guide --snippet` prints a short paragraph for your `AGENTS.md` or `CLAUDE.md`. 3. <span class="ms ms--shipped">v2</span> **An optional hook**, [`hallway hook check-command`](/agents/hook/), that catches a bare dev-server start before it runs and suggests the `hallway run` equivalent. ## Etiquette for agents These are in the agent guide too: - Never change an existing app's `port`. Apps hardcode their ports in scripts and configs (see [Your ports stay put](/guarantees/ports/)). - Never delete files outside `apps/` and `bookmarks/`. Removing an app through Hallway moves its file to `backups/`. - Prefer `expected: adhoc` for throwaway servers, so they don't show as down when they stop. - Don't touch `state/`, `cache/` or `generated/`: they're machine-written. ## MCP <span class="ms ms--shipped">v2</span> For clients that prefer typed tools, an [MCP server](/agents/mcp/) runs on the same port as the dashboard, at `/mcp`. It can describe apps and fully manage bookmarks, but by design it can't start, stop or install anything. --- # hallway run > The one command agents should use to start a dev server, so it never becomes an orphan. <span class="ms ms--shipped">v1</span> `hallway run` replaces the ad-hoc `npm run dev` that leaves orphaned servers behind. ```sh hallway run <slug> [--cwd DIR] [--port N] [--persist] [--name "Display name"] -- <command...> ``` For example: ```sh hallway run slides -- npx astro dev --port '$PORT' hallway run docs --persist -- npx vite --port '$PORT' --strictPort ``` Hallway sets `PORT` to the app's port in the server's environment, and replaces `$PORT` (or `${PORT}`, `$HOST`, `${HOST}`) in the command's arguments with the app's values when it generates the job. **Quote it**, as above: unquoted, your own shell would expand `$PORT` before Hallway sees it, usually to nothing. The app file keeps the command as written, so it stays right if you edit the file later. ## What it does 1. **Registers the app.** It creates or updates `apps/<slug>.yaml` with the command, the working directory (default: where you ran it) and the port: the app's existing port if it has one, the one you pinned with `--port`, or else a stable one assigned from 5100–5999. The app is `expected: adhoc`, or `running` with `--persist`. 2. **Hands the server to the OS's service manager** (a launchd job on macOS, a systemd user unit on Linux, a scheduled task on Windows), so it is owned by that, **not by your session**, and survives the session ending. - Without `--persist`, it won't come back after a reboot; it stays on the dashboard as `stopped`, is listed under [Lost at last reboot](/concepts/services/#lost-at-last-reboot), and `hallway run <slug>` starts it again. - With `--persist`, it is a full service that starts at login and restarts if it exits. 3. **Waits until the port is listening** (up to 30 seconds), then **prints the URL and the log path, and exits 0**. If the server doesn't come up (or exits first), it prints the last 30 lines of its logs and exits 1, and leaves the app declared so you can fix it and re-run. Because it returns promptly and prints the URL, an agent can run it inside a tool call without hanging. Run it again with no command to reuse the recorded one: ```sh hallway run slides ``` Companions: ```sh hallway stop slides hallway restart slides hallway logs slides --follow ``` ## Framework flags Hallway sets `PORT` and `HOST=127.0.0.1` in the server's environment. Frameworks that don't read `PORT` need a flag: | Framework | Command | | --- | --- | | Vite | `npx vite --port '$PORT' --strictPort` | | Astro | `npx astro dev --port '$PORT'` | | Next.js | `npx next dev -p '$PORT'` | Always pass `--strictPort` to Vite. Without it, Vite quietly takes another port when its own is busy, and the app moves away from the URL Hallway printed. ## Why launchd and systemd, and why not PM2 launchd is how macOS itself keeps things running, and systemd is how most Linux distributions do; both are reliable across reboots. On Windows, best effort, Task Scheduler starts the server at logon and Hallway's own small supervisor restarts it if it exits ([details](/concepts/services/#windows)). A second process manager (PM2, or a home-grown wrapper like the ones this command replaces) would be one more supervisor on top of them, with its own history of boot-integration problems. See [Services and reboots](/concepts/services/). ## Why it's CLI-only `hallway run` executes a command. That is safe in your shell, or in an agent's shell under that agent's own permission prompts. It is deliberately **not** available over [MCP](/agents/mcp/), where any agent with the server configured could use it to run arbitrary code. To catch an agent that starts a server directly anyway, add the optional [dev-server hook](/agents/hook/): it blocks the command and tells the agent the `hallway run` equivalent. --- # Agent guide and llms.txt > The guide Hallway prints for agents, the snippet for your instruction files, and the optional hook. ## The agent guide <span class="ms ms--v0">v0</span> Hallway ships an agent guide, available three ways: ```sh hallway guide # print it curl http://localhost:4255/agent-guide.md curl http://localhost:4255/llms.txt # a summary; /llms-full.txt has everything ``` Like Local Review's, it is rendered with **live context**: the daemon's version and URL, the data home path, the mode (direct or fronted) and base domain, and the slugs of the apps and bookmark groups that exist right now. What it covers: 1. What Hallway is, in two sentences. 2. **The rule: never start a long-running server directly.** Use `hallway run <slug> -- <command>`, and why, with the framework flags. 3. How to register an existing server: the minimal `apps/<slug>.yaml`, or `hallway app add`. 4. How to add bookmarks. 5. How to check status: `hallway status --json` or `GET /api/state`, including what was lost at the last reboot. 6. The HTTP API, the MCP server and the optional hook, and that `hallway setup` and `teardown` are the user's to run (they need sudo). 7. The file formats, generated from the same schema the validator uses, so the guide can't drift from the code. 8. [Etiquette](/agents/overview/#etiquette-for-agents): never change a port, never delete outside `apps/` and `bookmarks/`, prefer `adhoc` for throwaway servers, leave machine-written directories alone. ## The snippet for your instruction files <span class="ms ms--v0">v0</span> Agents follow what their instruction files tell them. `hallway guide --snippet` prints this, for your `AGENTS.md`, `CLAUDE.md` or equivalent: > Never start a long-running dev or preview server directly (`npm run dev`, `vite`, `astro dev`, `next dev`, > `python -m http.server`, …). Start it with `hallway run <slug> -- <command>`, which keeps it running outside this > session on a stable port and prints its URL. Run `hallway guide` for details. ## The optional hook <span class="ms ms--shipped">v2</span> For Claude Code, `hallway hook check-command` is a `PreToolUse` hook on its Bash tool. It reads the hook's payload from stdin and, if the command starts a dev server directly, denies it with a message giving the equivalent `hallway run` invocation. It is documented, never installed automatically: see [The dev-server hook](/agents/hook/) for what it catches and how to add it. ## This site's llms.txt This documentation site has its own [llms.txt](/llms.txt) and [llms-full.txt](/llms-full.txt), and every page is available as Markdown at its URL with `.md` instead of the trailing slash (this page is [/agents/agent-guide.md](/agents/agent-guide.md)). --- # MCP server > Typed tools for apps and bookmarks, served on the dashboard's own port - and why none of them launch anything. <span class="ms ms--shipped">v2</span> Available since 0.2.0. Hallway's MCP server gives agents sixteen typed tools for reading what's running, describing apps and managing bookmarks. It is a convenience layer over the same store the CLI, the HTTP API and your own hand edits go through: never a separate code path, and never the only way to do something. An agent that can edit files or run `hallway` already has everything MCP offers, and more. What it **isn't**: a process manager. No MCP tool starts, stops, restarts or installs anything (see [Describe, never launch](#describe-never-launch)). ## Connecting The server runs **in the daemon, on the daemon's own port**, at `/mcp`: `http://localhost:4255/mcp` by default. It uses MCP's Streamable HTTP transport, so there is one service and one port to remember, and because it shares the daemon's store and file watcher, every write through MCP shows up live on every open dashboard. It speaks the 2026-07-28 protocol revision, which is stateless (each request stands alone), and also serves clients of the 2025 revisions, which open with an `initialize` handshake, statelessly too. With Claude Code, over HTTP: ```sh claude mcp add --transport http hallway http://localhost:4255/mcp ``` If your daemon runs on another port (the `port` key in [config.yaml](/reference/config-yaml/)), use that in the URL. In [fronted mode](/concepts/modes/), the dashboard's own address works too: `https://hallway.localhost/mcp`, or `https://<base>/mcp` on a custom domain (`domain.dashboard` in config.yaml). The `localhost` URL keeps working in both modes and doesn't depend on Caddy running, so it's the one to register. ### Over stdio For clients that prefer to launch a stdio server, `hallway mcp` is a thin bridge: it forwards every message to the running daemon's `/mcp` and writes the replies back, so the daemon stays the only writer. It finds the daemon on the port in `config.yaml`, so it needs no URL. ```sh claude mcp add hallway -- hallway mcp ``` ### Other clients Any MCP client that supports Streamable HTTP can connect to `http://localhost:4255/mcp` (or your daemon's port) with no headers and no token. Clients that only launch stdio servers can run `hallway mcp` as the command, with no arguments. ## Tools Reading: | Tool | Input | Returns | | --- | --- | --- | | `get_status` | `{ filter? }` | Apps with state, URL and uptime; counts by state; how many listeners are observed; what was [lost at the last reboot](/concepts/services/#lost-at-last-reboot), read-only | | `list_apps` | `{ source?, state?, tag? }` | Apps with their config and live state | | `get_app` | `{ slug }` | One app in full, with identity and recent history | | `get_history` | `{ slug, window: '24h' \| '7d' \| '90d' }` | Uptime buckets and a latency summary | | `list_observed` | `{}` | Observed listeners, with identity | | `list_bookmarks` | `{ group? }` | Bookmark groups and their links | Apps: | Tool | Input | Effect | | --- | --- | --- | | `declare_app` | `{ slug, port, name?, description?, host?, expected?, health?, tags?, links?, icon? }` | Writes a new `apps/<slug>.yaml`. Creates only: fails if the app already exists | | `update_app` | `{ slug, patch, etag? }` | Applies `patch` as a JSON merge patch (`null` removes a key; lists are replaced whole). Can't add a `service` block or change `port` | | `remove_app` | `{ slug, etag? }` | Moves the file to `backups/removed/`. Refused for an app Hallway runs as a service: use `hallway app rm` | Observed listeners: | Tool | Input | Effect | | --- | --- | --- | | `claim_observed` | `{ id, slug, name?, expected? }` | The same as **Claim** in the dashboard: writes `apps/<slug>.yaml` from the observation | | `ignore_observed` | `{ id, by: 'port' \| 'process' \| 'dir' }` | Adds it to `ignore.yaml` | Bookmarks: | Tool | Input | Effect | | --- | --- | --- | | `upsert_bookmark_group` | `{ group, title?, order?, collapsed?, etag? }` | Creates or updates a group | | `add_bookmark` | `{ group, url, title?, description?, icon?, tags?, etag? }` | Adds a link | | `update_bookmark` | `{ group, url \| index, patch, etag? }` | Edits a link, found by its URL or its position | | `remove_bookmark` | `{ group, url \| index, etag? }` | Removes a link | | `remove_bookmark_group` | `{ group, etag? }` | Moves the group's file to `backups/` | ## Writes, ETags and conflicts Every write goes through the same store as the HTTP API, the CLI and the dashboard, so it is validated the same way, written atomically, and keeps the **comments and key order** of the YAML file it edits. It returns the file's new `etag`. Pass that `etag` (or the one from an earlier read) to a later write for optimistic concurrency: if the file changed in the meantime, whether by you in an editor, another agent or the dashboard, the write fails with a conflict and changes nothing. Re-read, then try again. Without an `etag`, the write applies to whatever is there. ## Describe, never launch Two data sets carry very different risks: - **Bookmarks** are inert, so full create, update and delete is safe. - **Apps** can be *described*: declared, edited, claimed, removed. But **no MCP tool starts, stops, restarts or installs a process.** A tool that registers "run this command" would be an arbitrary-code-execution surface for any agent that has the server configured, outside the shell's own permission prompts. So: - `declare_app` and `update_app` refuse a `service` block. - `update_app` refuses to change `port`: apps hardcode their ports, so a changed port breaks things elsewhere (see [Your ports stay put](/guarantees/ports/)). Change it yourself if you really mean to. - `remove_app` refuses an app Hallway runs as a service, because removing it would stop a process. - `get_status` reports what was lost at the last reboot, but restarting any of it is `hallway lost restart` or the dashboard, never MCP. - `run`, `start`, `stop`, `restart`, `service`, `lost`, `thumbs`, `setup` and `teardown` are **CLI-only**, where you, or an agent in your shell under its own permission prompts, run them explicitly. An agent with a shell starts servers with [`hallway run`](/agents/hallway-run/). A refusal comes back as a tool error that says why and names the shell command to use instead. A `restart_app` tool, limited to services already installed through the CLI, was considered and rejected: it would still start a process. ## Resources Two resources carry the same data as the dashboard, as JSON: | URI | Contents | | --- | --- | | `hallway://state` | Everything the dashboard shows: apps, bookmarks, observed listeners, problems | | `hallway://apps/<slug>` | One app, as `get_app` returns it | Clients of the 2026-07-28 revision can subscribe (`subscriptions/listen`) and receive `notifications/resources/updated` whenever a resource changes, including after a hand edit to a file. ## Security The [localhost security rules](/guarantees/localhost-security/) apply to `/mcp` exactly as to the rest of the daemon, as MCP's own guidance for Streamable HTTP servers asks: - The daemon listens on `127.0.0.1` only. - A request whose `Host` or `Origin` isn't local gets `403`. This is what stops DNS rebinding and other web pages. - Requests must be JSON (`Content-Type: application/json`). There is **no auth token**. The trust boundary is the same as the data home's: anything running as you can already edit `~/.hallway` directly, so a token would add friction without adding protection. That holds because MCP can't start processes; it would be revisited if that ever changed. ## Troubleshooting **The daemon isn't running.** The tools live in the daemon, so an HTTP client gets a connection error, and `hallway mcp` exits with status 1 and a message saying so. Start the daemon with `hallway service install` (it then runs at every login) or `hallway serve` (in the foreground), then reconnect or restart the MCP server in your client. `hallway doctor` shows whether the daemon's service is installed and running. **The daemon is on a different port.** The HTTP URL must use the daemon's port: check the `port` key in `~/.hallway/config.yaml` and re-register with that URL. `hallway mcp` reads `config.yaml` itself, so it follows the port without changes. (`$PORT` in your shell doesn't move it: that is usually some app's port, not Hallway's.) **A write fails with a conflict.** The file changed since the `etag` you passed was read. Read it again and retry. --- # The dev-server hook > An optional Claude Code hook that stops an agent starting a dev server directly, and tells it the hallway run command to use instead. <span class="ms ms--shipped">v2</span> Available since 0.2.0. Instruction files help, but agents still reach for `npm run dev` out of habit, and every server started that way dies with the session. `hallway hook check-command` is an optional Claude Code `PreToolUse` hook for the Bash tool that catches those commands **before they run** and tells the agent the [`hallway run`](/agents/hallway-run/) equivalent. It is never installed automatically: you add it, and you can remove it. ## What it does The hook reads Claude Code's payload on stdin. If the command starts a dev or preview server directly, it prints Claude Code's JSON decision, a `deny` with a reason (`hookSpecificOutput.permissionDecision: "deny"`), and exits 0. The agent sees the reason and can rerun the server the right way. Anything else gets no output, and the command runs as normal. It recognises the common dev-server commands, including `npm run dev`, `npm start`, `pnpm dev`, `yarn dev`, `bun run dev`, `vite`, `astro dev`, `next dev`, `nuxi dev`, `ng serve`, `python -m http.server`, `manage.py runserver`, `uvicorn`, `flask run`, `php -S`, `hugo server`, `jekyll serve` and `npx serve`. It still finds them behind `cd dir &&`, `nohup … &`, environment assignments, and `npx`, `pnpm exec` or `uv run`. | Command | Decision | | --- | --- | | `npm run dev` | Blocked: `hallway run my-app -- npm run dev` | | `cd web && pnpm dev` | Blocked: `hallway run my-app -- pnpm dev` | | `astro dev --port 4321` | Blocked: `hallway run my-app -- astro dev --port '$PORT'` | | `PORT=3000 npx next dev` | Blocked: `hallway run my-app -- npx next dev -p '$PORT'` | | `nohup python -m http.server 8000 &` | Blocked: `hallway run my-app -- python -m http.server '$PORT'` | | `npm run build`, `npm test`, `npm install` | Allowed | | `vite --help` | Allowed | | `echo "npm run dev"` | Allowed (a mention inside a string or comment) | | `hallway run my-app -- vite --port '$PORT'` | Allowed | The suggested slug comes from the name of the working directory (here, `~/Code/my-app`), and a fixed port such as `--port 3000` becomes `'$PORT'`, so the server listens on the port Hallway assigns. Anything the hook can't parse is let through: it would rather miss a server than block a command it doesn't understand. ### The message the agent sees For `vite --port 3000`, run in `~/Code/my-app`: ```text Hallway: don't start a Vite dev server directly (`vite --port 3000`): it would die with this session and land on a port nobody can find. Run it with Hallway instead, which keeps it running outside this session on a stable port and prints its URL: hallway run my-app -- vite --port '$PORT' `hallway run my-app` again restarts it; `hallway status` lists what's running; `hallway logs my-app` shows its output. ``` When the command has no port flag for Hallway to rewrite, the message also says that the server must listen on `$PORT`, and how to pass it. ## Installing it Add this to `.claude/settings.json` in a project, for that project only, or to `~/.claude/settings.json`, for every project: ```json { "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [{ "type": "command", "command": "hallway hook check-command" }] } ] } } ``` If the file already has a `hooks` section, add the entry to its `PreToolUse` list rather than replacing it. The `hallway` binary must be on the `PATH` Claude Code runs hooks with. ## Removing it Delete the entry from the settings file. Nothing else was installed, so there is nothing else to undo. ## Known false positives `npm start` (and `yarn start`, and the like) is blocked even in a package whose `start` script isn't a server, since the hook only sees the command, not the script. The agent can ask you to run it, or you can run it yourself. --- # CLI > Every hallway command and its flags, as `hallway --help` lists them, with the milestone each belongs to. The binary is `hallway`. (A short alias, `hw`, has been proposed but not decided.) Every listing command takes `--json` for agents and scripts. `hallway --help` prints the same list, grouped the same way. **Exit codes:** `0` success, `1` error, `2` usage error (the usage is printed). Each command is marked with the milestone it belongs to. Everything on this page is in the current release. ## The daemon | Command | Description | | | --- | --- | --- | | `hallway` / `hallway serve [--port N] [--quiet]` | Run the daemon in the foreground (the default command): the dashboard and API on `http://localhost:<port>`, 127.0.0.1 only. The port is `--port`, else `$PORT`, else `port:` in `config.yaml`, else 4255. `--quiet` (`-q`) prints just the "Hallway on <url>" line. | <span class="ms ms--v0">v0</span> | | `hallway open` | Open the dashboard in your browser (`https://hallway.localhost`, or your domain's, in fronted mode). | <span class="ms ms--v0">v0</span> | | `hallway service install` | Generate and load the daemon's service, so it runs at every login: `~/Library/LaunchAgents/hallway.daemon.plist` on macOS, `~/.config/systemd/user/hallway-daemon.service` on Linux, the scheduled task `\hallway.daemon` on Windows (best effort; starts at logon). Idempotent. | <span class="ms ms--v0">v0</span> | | `hallway service uninstall` | Unload and remove it. | <span class="ms ms--v0">v0</span> | | `hallway service status [--json]` | Whether the daemon's job is loaded and running, and its last exit code. | <span class="ms ms--v0">v0</span> | | `hallway service restart` | Restart the daemon's service. | <span class="ms ms--v0">v0</span> | | `hallway --version`, `hallway --help` | | <span class="ms ms--v0">v0</span> | ## Apps and discovery | Command | Description | | | --- | --- | --- | | `hallway status [slug] [--json]` | Every app's state, address, port and 24-hour uptime. Works without the daemon (it reads the files and probes once), but is richer with it. | <span class="ms ms--v0">v0</span> | | `hallway scan [--json]` | Look for listeners nobody has named, and print them with identity. | <span class="ms ms--v0">v0</span> | | `hallway claim <port\|id> [--slug S] [--name N] [--expected E]` | Turn an observed listener into a declared app. | <span class="ms ms--v0">v0</span> | | `hallway ignore <port\|id> [--by port\|process\|dir]` | Hide an observed listener. | <span class="ms ms--v0">v0</span> | | `hallway lost [--json]` | What was running before the last reboot and isn't now. See [Lost at last reboot](/concepts/services/#lost-at-last-reboot). | <span class="ms ms--shipped">v1</span> | | `hallway lost restart\|service <id\|port> [--slug S]` | Start one again: once, or as a service, the way `hallway run` would. Needs the daemon. | <span class="ms ms--shipped">v1</span> | | `hallway lost dismiss <id\|port>` / `hallway lost dismiss --all` | Clear one, or the whole notice. | <span class="ms ms--shipped">v1</span> | | `hallway app add <slug> [--port N] [--name N] [--expected E] [--health PATH]` | Declare an app (writes `apps/<slug>.yaml`); without `--port` it gets a free port from the assignment range. | <span class="ms ms--v0">v0</span> | | `hallway app ls [--json]` | List the declared apps. | <span class="ms ms--v0">v0</span> | | `hallway app edit <slug>` | Open the app's YAML in `$VISUAL` or `$EDITOR`. | <span class="ms ms--v0">v0</span> | | `hallway app rm <slug>` | Remove an app: its file moves to `backups/removed/`, and its service is removed first if it has one. | <span class="ms ms--v0">v0</span> | ## Running servers | Command | Description | | | --- | --- | --- | | `hallway run <slug> [--cwd DIR] [--port N] [--persist] [--name N] -- <command...>` | Start a server as a user service (launchd, systemd, or a scheduled task on Windows) on a stable port, wait for it to listen, print its URL and exit. `$PORT` in the command (quote it: `'$PORT'`) becomes the app's port. See [hallway run](/agents/hallway-run/). | <span class="ms ms--shipped">v1</span> | | `hallway stop <slug>` / `start <slug>` / `restart <slug>` | Stop, start or restart a managed app. | <span class="ms ms--shipped">v1</span> | | `hallway logs <slug> [--lines N] [--follow]` | Tail a managed app's logs. | <span class="ms ms--shipped">v1</span> | | `hallway service add <slug> [-- <command...>]` | Keep a declared app running: at login, and restarted if it exits. | <span class="ms ms--shipped">v1</span> | | `hallway service rm <slug>` | Stop managing it; the app stays declared. | <span class="ms ms--shipped">v1</span> | | `hallway service regenerate [slug]` | Rewrite the generated services, e.g. after a Node upgrade broke an absolute path. | <span class="ms ms--shipped">v1</span> | ## Bookmarks | Command | Description | | | --- | --- | --- | | `hallway bookmark add <group> <url> [--title T] [--description D]` | Add a link to a group (creating the group if needed). | <span class="ms ms--v0">v0</span> | | `hallway bookmark ls [group] [--json]` | List groups and links. | <span class="ms ms--v0">v0</span> | | `hallway bookmark rm <group> <url\|number>` | Remove a link, by its URL or its position in the list. | <span class="ms ms--v0">v0</span> | | `hallway bookmark edit <group>` | Open the group's YAML in `$VISUAL` or `$EDITOR`. | <span class="ms ms--v0">v0</span> | ## Fronted mode | Command | Description | | | --- | --- | --- | | `hallway setup [--domain BASE] [--print]` | Run Caddy for your apps, redirect ports 443 and 80 to it and trust its certificates, so apps are at `https://<app>.localhost` (or under `BASE`). Prints each step first; the privileged ones run through `sudo`. `--print` shows the plan and runs nothing. macOS and Linux only: on Windows it refuses and changes nothing. See [Setup](/networking/setup-and-doctor/). | <span class="ms ms--shipped">v1</span> | | `hallway teardown [--uninstall-packages] [--print]` | Undo setup and go back to `http://localhost:<port>` links. `--uninstall-packages` also uninstalls Homebrew's Caddy and dnsmasq. | <span class="ms ms--shipped">v1</span> | ## Coding agents | Command | Description | | | --- | --- | --- | | `hallway guide [--snippet]` | Print the agent guide (`AGENT-GUIDE.md`), or just the paragraph for an agent instruction file (`CLAUDE.md`, `AGENTS.md`). | <span class="ms ms--v0">v0</span> | | `hallway mcp` | An MCP server on stdio: a bridge that forwards every message to the running daemon's `/mcp`, found on `config.yaml`'s port. Exits 1 with a message if the daemon isn't running. See [MCP server](/agents/mcp/). | <span class="ms ms--shipped">v2</span> | | `hallway hook check-command` | A pre-tool-use hook (Claude Code's `PreToolUse` on Bash): reads the payload on stdin and denies a bare dev-server start, suggesting the `hallway run` for it. See [The dev-server hook](/agents/hook/). | <span class="ms ms--shipped">v2</span> | ## Thumbnails | Command | Description | | | --- | --- | --- | | `hallway thumbs enable` | Screenshots of your apps on their doors: installs a browser (Chromium's headless shell, about 300 MB) into the data home, never anywhere else, and sets `thumbnails.mode: screenshot`. Healthy apps get a picture within a minute. | <span class="ms ms--shipped">v2</span> | | `hallway thumbs disable` | Turn them off: sets `thumbnails.mode: favicon` and deletes the browser and the screenshots. | <span class="ms ms--shipped">v2</span> | | `hallway thumbs refresh [slug]` | Take new screenshots now, of one app or every healthy one. Needs the daemon, which does the shooting. | <span class="ms ms--shipped">v2</span> | | `hallway thumbs status` | The mode, whether a browser is installed and how big it is, and how many screenshots there are. | <span class="ms ms--shipped">v2</span> | See [Thumbnails](/concepts/thumbnails/). ## Other | Command | Description | | | --- | --- | --- | | `hallway doctor [--json]` | Check the daemon, the data home, the services and fronted mode, with suggested fixes. Read-only. See [doctor](/networking/setup-and-doctor/#hallway-doctor). | <span class="ms ms--v0">v0</span>; fronted-mode checks <span class="ms ms--shipped">v1</span> | Environment variables are on [their own page](/reference/environment/). --- # config.yaml > Global settings for the data home - the daemon port, mode, domain, proxy, probing, services, scanning, thumbnails and the dashboard. `~/.hallway/config.yaml` holds the global settings. Every key is optional; the values below are the defaults. It is the only file with a `version`, so a future format change can be detected and migrated. ```yaml version: 1 port: 4255 # the daemon's port (it listens on 127.0.0.1 only) mode: direct # direct | fronted (`hallway setup` switches it to fronted) domain: base: localhost # apps are served at <slug>.<base> dashboard: hallway.localhost # where the dashboard lives in fronted mode. # Default: "hallway.localhost" when base is "localhost"; # the bare apex "<base>" for any custom domain. tls: true # fronted mode is always HTTPS; kept explicit for clarity dnsPort: 4253 # custom domains only: where Hallway's own dnsmasq answers proxy: caddy: null # the Caddy binary; default: `caddy` on your PATH, else Homebrew's httpsPort: 4443 # the loopback port Caddy serves HTTPS on (as you) httpPort: 4480 # the loopback port Caddy serves HTTP on (a redirect to HTTPS) publicPort: 443 # the HTTPS port in links; set it to httpsPort for no redirect and no sudo probe: intervalSeconds: 60 liveIntervalSeconds: 15 # used while at least one dashboard is open timeoutMs: 5000 concurrency: 8 failuresBeforeUnhealthy: 2 # flap damping ports: assignRange: [5100, 5999] # for new apps with no port yet; existing ports are never changed services: startGraceSeconds: 30 # "starting" before "not listening" counts as down logMaxBytes: 10485760 # rotate managed-service logs above this size logGenerations: 3 scan: enabled: true intervalSeconds: 120 includeEphemeralPorts: false # skip 49152–65535 by default (IDE and tool helpers live there) minPort: 1024 thumbnails: mode: favicon # favicon | screenshot (after `hallway thumbs enable`) | card dashboard: title: null # defaults to the machine's hostname sections: [apps, observed, bookmarks] # order and presence; remove one to hide it theme: auto # auto | dark | light ``` ## Keys | Key | Default | Meaning | | --- | --- | --- | | `port` | `4255` | The daemon's port. `--port` and `$PORT` override it. Never 5000 or 7000 (macOS's AirPlay Receiver). | | `version` | `1` | The data home's format. Always `1` for now; a file written by a newer Hallway is reported, not misread. | | `mode` | `direct` | `direct` links to `http://localhost:<port>`; `fronted` serves apps through Caddy at `https://<slug>.<base>`. Set by `hallway setup` and `hallway teardown`, rather than by hand. See [modes](/concepts/modes/). <span class="ms ms--shipped">v1</span> | | `domain.base` | `localhost` | Base domain for fronted mode. See [Custom domains](/networking/custom-domains/). <span class="ms ms--shipped">v1</span> | | `domain.dashboard` | `hallway.localhost`, or `<base>` | The dashboard's hostname in fronted mode. Its first label is then a reserved slug. <span class="ms ms--shipped">v1</span> | | `domain.tls` | `true` | Fronted mode is always HTTPS. <span class="ms ms--shipped">v1</span> | | `domain.dnsPort` | `4253` | Custom domains only: the loopback port Hallway's own dnsmasq answers on; the resolver entry points at it. <span class="ms ms--shipped">v1</span> | | `proxy.caddy` | `caddy` on your `PATH`, else Homebrew's | The Caddy binary (2.7 or later). <span class="ms ms--shipped">v1</span> | | `proxy.httpsPort` | `4443` | The loopback port Caddy, running as you, serves HTTPS on. <span class="ms ms--shipped">v1</span> | | `proxy.httpPort` | `4480` | The loopback port Caddy serves HTTP on (a redirect to HTTPS). <span class="ms ms--shipped">v1</span> | | `proxy.publicPort` | `443` | The HTTPS port in links: 443 once `hallway setup` has redirected it to `httpsPort`. Set it to the same value as `httpsPort` for fronted mode with no redirect (links then carry `:4443`). See [Fronted mode](/networking/fronted-mode/#fronted-mode-without-the-redirect). <span class="ms ms--shipped">v1</span> | | `probe.intervalSeconds` | `60` | Seconds between health probes (±10% jitter). | | `probe.liveIntervalSeconds` | `15` | The interval while a dashboard is connected. | | `probe.timeoutMs` | `5000` | An HTTP probe slower than this counts as unhealthy. | | `probe.concurrency` | `8` | Probes in flight at once. | | `probe.failuresBeforeUnhealthy` | `2` | Consecutive failures needed to leave `healthy`. | | `ports.assignRange` | `[5100, 5999]` | Where new apps' ports come from. | | `services.startGraceSeconds` | `30` | A service just started is `starting`, not `down`, for this long. Also how long "Lost at last reboot" waits for login services. <span class="ms ms--shipped">v1</span> | | `services.logMaxBytes` | `10485760` | Rotate a service's log above this size (10 MB). <span class="ms ms--shipped">v1</span> | | `services.logGenerations` | `3` | Rotated logs to keep. <span class="ms ms--shipped">v1</span> | | `scan.enabled` | `true` | Turn the port scanner off entirely. | | `scan.intervalSeconds` | `120` | Seconds between scans. | | `scan.includeEphemeralPorts` | `false` | Also show listeners on 49152–65535. | | `scan.minPort` | `1024` | Ignore ports below this. | | `thumbnails.mode` | `favicon` | The pictures on the doors. `favicon`: the app's favicon (a monogram if it has none). `screenshot`: a picture of each healthy app, as the door's glass; run `hallway thumbs enable` first, which installs the browser and sets this. `card`: every door drawn as a generated card with its name, address, framework and `repo @ branch`. See [Thumbnails](/concepts/thumbnails/). <span class="ms ms--shipped">v2</span> | | `dashboard.title` | hostname | The page title. | | `dashboard.sections` | `[apps, observed, bookmarks]` | Which sections show, and in what order. | | `dashboard.theme` | `auto` | `auto` follows the system; or force `dark` or `light`. | ## Rules for every file These hold for `config.yaml` and every other editable file in the data home: - **Unknown keys are preserved** when Hallway writes a file, and reported as warnings. They are never deleted. - **Comments and key order are preserved** on round-trip edits wherever possible. - **An invalid file never crashes the daemon.** Hallway keeps the last good version in memory, shows the error (file, line and message) in the dashboard, and keeps serving everything else. It never overwrites a file you broke. --- # apps/<slug>.yaml > The format of a declared app - one file per app, named after its slug. Each declared app is one file, `~/.hallway/apps/<slug>.yaml`. **The filename is the slug.** The slug becomes a hostname label in fronted mode, so it must be a valid DNS label: lowercase letters, digits and hyphens, starting and ending with a letter or digit, at most 63 characters (`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`). The dashboard's own label (`hallway` by default), `www` and `api` are reserved. ## Minimal ```yaml name: Slides port: 4321 ``` ## Everything ```yaml name: Slides description: Quarterly review deck port: 4321 # REQUIRED. The port the app actually listens on. Never auto-changed. host: 127.0.0.1 # the upstream host; default 127.0.0.1 expected: running # running | stopped | adhoc # running: should always be up; not responding = "down" # stopped: intentionally off; shown calmly as "stopped" # adhoc: up sometimes; not responding = "stopped", not "down" health: # optional; without it, Hallway probes GET / path: /api/health expectStatus: 200 # optional; default: any 2xx when path is given proxy: # fronted mode only preserveHost: false # default false: Host is rewritten to the upstream aliases: [] # extra hostnames, e.g. [deck] → deck.<base> service: # optional. Present = Hallway keeps it alive (launchd / systemd). command: ["npx", "astro", "dev", "--port", "4321"] # an argv array, resolved to absolute paths cwd: ~/Code/slides env: {} # PORT and HOST are set automatically runAtLoad: true keepAlive: true links: # optional deep links shown on the card - { label: Speaker notes, path: /notes } project: # optional; filled in automatically when claimed dir: ~/Code/slides repo: https://github.com/example/slides tags: [decks] icon: auto # auto (favicon) | an emoji | a path to an image in the data home | a URL ``` ## Fields | Field | Required | Meaning | | --- | --- | --- | | `name` | | Display name. | | `description` | | One line, shown in the detail drawer. | | `port` | **Yes** | The port the app listens on. Hallway [never changes it](/guarantees/ports/). | | `host` | | The upstream host, default `127.0.0.1`. | | `expected` | | `running`, `stopped` or `adhoc`: decides whether "not listening" is `down` or `stopped`. See [Health states](/concepts/health/#stopped-on-purpose-is-not-down). | | `health.path` | | Probe this path instead of `/`. | | `health.expectStatus` | | The status that means healthy; default any 2xx. | | `proxy.preserveHost` | | Pass the browser's `Host` through instead of rewriting it. See [Host-header rewriting](/networking/fronted-mode/#host-header-rewriting). <span class="ms ms--shipped">v1</span> | | `proxy.aliases` | | Extra hostname labels for the app: `[deck]` also serves it at `deck.<base>`. <span class="ms ms--shipped">v1</span> | | `service` | | Makes the app a [managed service](/concepts/services/#managed-services). Write it with `hallway run` or `hallway service add` rather than by hand. <span class="ms ms--shipped">v1</span> | | `service.command` | With `service` | The command as an argv array, not a shell string. | | `service.cwd` | | Working directory. | | `service.env` | | Extra environment variables. `PORT`, `HOST` and `HOSTNAME` are set for you. | | `service.runAtLoad`, `service.keepAlive` | | Start at login; restart if it exits. | | `links` | | Deep links on the card: a `label`, and either a `path` on the app or a full `url`. | | `project.dir`, `project.repo` | | Where the code lives. | | `tags` | | Free-form tags, for filtering. | | `icon` | | `auto` uses the app's favicon. | ## Validation Hallway validates before it writes (rejecting bad slugs, ports outside 1–65535 and unknown values) and validates what it reads. Two declared apps on the same port is a **warning**, not an error, because one of them may be `stopped`. A file that fails to parse or validate keeps its last good version on the dashboard, with the error on its card. Fix the file and the error clears. ## Removing an app Delete it from the dashboard, with `hallway app rm`, through the API or MCP: the file is **moved to `backups/removed/`, never deleted**, so a mistake (yours or an agent's) is recoverable. Deleting the file yourself works too; the card disappears. --- # Bookmarks and ignore files > The formats of bookmarks/<group-slug>.yaml and ignore.yaml. ## `bookmarks/<group-slug>.yaml` <span class="ms ms--v0">v0</span> One file per bookmark group. The filename is the group's slug. ```yaml title: Production order: 10 # groups sort by order, then title collapsed: false links: - title: Admin console url: https://admin.example.com description: Production admin icon: auto # auto = fetch the favicon once and cache it; or an emoji / image / URL tags: [prod] - title: BI dashboards url: https://bi.example.com ``` | Field | Meaning | | --- | --- | | `title` | The group's heading. | | `order` | Sort key; groups sort by `order`, then `title`. | | `collapsed` | Whether the group starts collapsed. Saved here, so it is the same in every browser. | | `links[].title` | The chip's label. | | `links[].url` | Where it goes. Required. | | `links[].description` | Shown as a tooltip. | | `links[].icon` | `auto` (the site's favicon, fetched once and cached), an emoji, an image in the data home, or a URL. Without one, a monogram on a colour derived from the hostname. | | `links[].tags` | Free-form tags, for filtering. | Bookmarks are inert: nothing is launched, and they are not health-checked. See [Bookmarks](/concepts/bookmarks/). ## `ignore.yaml` <span class="ms ms--v0">v0</span> Observed listeners you never want to see. **Ignore** in the dashboard (or `hallway ignore`) appends to it; you can edit it by hand. ```yaml ports: [631] # never show these ports processes: [rapportd, ControlCenter] # never show listeners owned by these process names dirs: [] # never show listeners whose working directory is under these paths ``` This is on top of the scanner's own filters (ports below 1024, the ephemeral range, and known non-web daemons). See [the scanner](/concepts/apps/#observed-listeners-the-scanner). --- # The data home > Where Hallway keeps everything - which files are yours to edit and which are machine-written. Everything Hallway knows lives in one directory, the **data home**: `~/.hallway`, or wherever `$HALLWAY_HOME` points. The override is optional: with an empty environment (as launchd jobs and many agent shells have), the default just works. ```text ~/.hallway/ config.yaml # global settings (yours to edit) apps/ <slug>.yaml # one declared app per file (yours to edit) bookmarks/ <group-slug>.yaml # one bookmark group per file (yours to edit) ignore.yaml # observed listeners you chose to hide (yours to edit) state/ # machine-written; never hand-edit daemon.json # the running daemon's port, so the CLI can find it health.sqlite # probe samples and hourly rollups observed.json # last-known identity of every observed listener, first and last seen boot.json # the last boot time seen, to detect reboots lost.json # what the last reboot took down, until dismissed caddy/ # fronted mode: Caddy's admin socket (admin.sock, mode 0600), # its local certificate authority and certificates (data/) cache/ # machine-written; regenerable favicons/ # apps' and bookmarks' favicons thumbs/<slug>.png, .json # screenshots (after `hallway thumbs enable`) browser/ # the headless browser screenshots use (about 300 MB) iana-tlds.txt # IANA's TLD list, for custom-domain checks (a week) generated/ # machine-written; regenerable caddy.json # the configuration Caddy runs from (fronted mode) fallback.html # the page Caddy serves when the daemon is down dnsmasq.conf # custom domains only setup/ # files `hallway setup` stages before installing them with sudo logs/ daemon.out.log, daemon.err.log <slug>.out.log, <slug>.err.log # managed services backups/ <ISO-timestamp>/ # snapshots of the editable files removed/ # files removed through Hallway ``` ## Yours, and Hallway's **What you edit and what Hallway writes are separated by directory.** Everything outside `state/`, `cache/`, `generated/`, `logs/` and `backups/` is meant for people and agents to edit. The machine-written files are deliberately not YAML, so nobody is tempted to edit them. The formats: [config.yaml](/reference/config-yaml/), [apps/<slug>.yaml](/reference/app-yaml/), [bookmarks and ignore.yaml](/reference/bookmarks-yaml/). ## One file per thing Each app and each bookmark group is its own file. Agents writing at the same time touch different files, diffs stay small, and a broken file breaks one card, not everything. (Local Review stores its projects the same way, for the same reasons.) ## How writes happen - **Atomically**: a temporary file, then a rename. The watcher ignores the temporary files. - **Validated first**: bad slugs, out-of-range ports and unknown values are rejected before anything is written. - **Safely concurrent**: API and MCP writes can send the ETag they read; if the file changed in between, the write is refused and the caller re-reads. - **Live**: a change from anywhere (your editor, an agent, the CLI, the API) reaches every open dashboard within about a second. - **Never destructive**: removing an app or a bookmark group moves its file into `backups/removed/`. Hallway also snapshots the editable files into `backups/` when the daemon starts, hourly while it runs (if anything changed), and before anything destructive. Deleting `state/` loses history (uptime, what was observed, the lost-at-reboot notice) but nothing you wrote. In fronted mode, though, `state/caddy/` holds the certificate authority your system trusts: delete it and you need `hallway setup` again. ## Keeping it in git The data home can be a git repository if you want history, or to share bookmarks across machines through your dotfiles. Hallway generates a `.gitignore` inside it that excludes `state/`, `cache/`, `generated/`, `logs/` and `backups/`. ## Outside the data home Hallway writes nowhere else, except: - <span class="ms ms--v0">v0</span> the daemon's service definition, when you run `hallway service install`: `~/Library/LaunchAgents/hallway.daemon.plist` on macOS, `~/.config/systemd/user/hallway-daemon.service` on Linux, the scheduled task `\hallway.daemon` and its files in `%LOCALAPPDATA%\hallway\tasks\` on Windows; - <span class="ms ms--shipped">v1</span> one per managed service: `~/Library/LaunchAgents/hallway.app.<slug>.plist` on macOS, `~/.config/systemd/user/hallway-app-<slug>.service` on Linux, `\hallway.app.<slug>` on Windows; - <span class="ms ms--shipped">v1</span> during `hallway setup` only (macOS and Linux): the user-level Caddy (and dnsmasq) jobs, and the privileged pieces it prints and runs through `sudo`: the 443/80 redirect, the trusted root certificate and, for a custom domain, the resolver entry. The full list is in [Setup](/networking/setup-and-doctor/#what-it-changes-outside-the-data-home); `hallway teardown` removes them. - <span class="ms ms--shipped">v2</span> npm's own download cache (usually `~/.npm`), which the `npm install` behind `hallway thumbs enable` passes through. The browser itself goes into `cache/browser/`. --- # HTTP API > The daemon's JSON API and its live event stream. The daemon serves a JSON API on its own port (`http://localhost:4255` by default). The dashboard, the CLI and the MCP server all use it, and so can you. Every route is behind the [localhost security rules](/guarantees/localhost-security/): writes must be `Content-Type: application/json`. Every route below is in the current release. In fronted mode the same API is also reachable through `https://hallway.localhost` (or your custom domain's apex). | Method | Path | Purpose | | --- | --- | --- | | `GET` | `/api/health` | `{ ok: true, version }`: the daemon's own health. | | `GET` | `/api/state` | Everything the dashboard needs, in one call: apps with live state, bookmarks, observed listeners, what was lost at the last reboot (`lost`), a config summary and any file errors. | | `GET` | `/api/config` | The resolved `config.yaml`, with an ETag. | | `GET` | `/api/apps` | Declared and discovered apps, with current state. | | `GET` | `/api/apps/:slug` | One app: config, state, recent history, identity and file errors. | | `PUT` | `/api/apps/:slug` | Create or replace a declared app (ETag / `If-Match`). | | `PATCH` | `/api/apps/:slug` | Partial update, as a JSON merge patch (`null` removes a key). | | `DELETE` | `/api/apps/:slug` | Remove a declared app (its file moves to `backups/removed/`; never hard-deleted). | | `GET` | `/api/apps/:slug/history?window=24h\|7d\|90d` | Uptime buckets and latency figures. The 7- and 90-day windows come from hourly rollups. | | `GET` | `/api/apps/:slug/thumb` | The thumbnail image, with an ETag: a favicon, a screenshot, or a generated 640×400 PNG card. The app's `thumbKind` says which. See [Thumbnails](/concepts/thumbnails/). | | `POST` | `/api/thumbs/refresh` | Take new screenshots now: `{"slug": "..."}` for one app, `{}` for every healthy one. Answers `{"taken": n}` once they're done; 409 when screenshots are off. | | `POST` | `/api/apps/:slug/service/:action` | `start`, `stop` or `restart` a managed service. | | `GET` | `/api/apps/:slug/logs?lines=200` | The tail of a managed service's logs. | | `GET` | `/api/bookmarks` | All bookmark groups. | | `GET` | `/api/bookmarks/:group` | One group, with an ETag. | | `PUT` / `PATCH` / `DELETE` | `/api/bookmarks/:group` | Create or replace, merge-patch, or remove a group (removing moves its file to `backups/removed/`). | | `GET` | `/api/bookmarks/:group/icon?url=...` | The cached favicon of a link in that group (only for a URL that is in the group); 204 when there is none yet. | | `GET` | `/api/observed` | Observed listeners, with identity. | | `POST` | `/api/observed/:id/claim` | Body `{ slug, name?, expected? }`: writes `apps/<slug>.yaml` from the observation. | | `POST` | `/api/observed/:id/ignore` | Add it to `ignore.yaml` (by port, process or directory). | | `POST` | `/api/scan` | Run a scan now. | | `GET` | `/api/lost` | [Lost at last reboot](/concepts/services/#lost-at-last-reboot): `{ lost }`, null when there's nothing. Also `lost` in `/api/state`. | | `POST` | `/api/lost/:id/restart`, `/api/lost/:id/service` | Body `{}` or `{ "slug": "..." }`. Start it again, once or as a service, as `hallway run` would. Answers 200 even when it didn't come up, with `ok: false`, the reason and the last log lines. | | `POST` | `/api/lost/:id/dismiss`, `/api/lost/dismiss` | Dismiss one, or the whole notice. | | `GET` | `/api/events` | The live event stream (below). | | `GET` | `/llms.txt`, `/llms-full.txt`, `/agent-guide.md` | The [agent guide](/agents/agent-guide/), with live data. | | `POST` | `/mcp` | The [MCP server](/agents/mcp/), Streamable HTTP and stateless. `GET` and `DELETE` get 405. | ## Concurrency Reads return an `ETag` (a hash of the file's bytes). Writes may send `If-Match`; if the file has changed since, the write gets `409 Conflict` and the caller should re-read. ## Live events `GET /api/events` is a Server-Sent Events stream: a `hello` event first, then `change` events with incrementing IDs, and a `: ping` comment every 25 seconds to keep the connection alive. Bursts of file writes are debounced (about 150 ms per item) into one event. ```ts type ServerEvent = | { type: 'app'; slug: string; reason: 'config' | 'removed' | 'error' } | { type: 'apps'; reason: string } // the list changed | { type: 'state'; slug: string; state: AppState } // health transitions only, not every probe | { type: 'bookmarks'; group?: string } | { type: 'observed'; reason: 'scan' | 'claimed' | 'ignored' | 'config' } | { type: 'thumb'; slug: string } // a favicon, screenshot or card changed | { type: 'config' } | { type: 'reboot'; lost: string[] }; // the lost-at-reboot notice changed ``` Treat an event as a hint to refetch the affected resource, not as data: that is what the dashboard does. --- # Environment variables > The few environment variables Hallway reads, and the ones it sets for managed services. Hallway needs **no environment variables** to work. Agents often run commands from shells that load no profile, and launchd jobs and systemd units inherit no shell environment, so every default works with an empty environment. These are overrides. ## Read by Hallway | Variable | Meaning | | --- | --- | | `HALLWAY_HOME` | The [data home](/reference/data-home/). Default `~/.hallway`. | | `PORT` | The daemon's port for `hallway serve`. Default `4255`. `--port` does the same. | | `VISUAL`, `EDITOR` | The editor `hallway app edit` and `hallway bookmark edit` open (`VISUAL` first; default `vi`). | | `XDG_CONFIG_HOME` | Linux: where systemd user units go (`$XDG_CONFIG_HOME/systemd/user`; default `~/.config/systemd/user`). | | `LOCALAPPDATA` | Windows: where the scheduled tasks' files go (`%LOCALAPPDATA%\hallway\tasks`; default `~\AppData\Local\hallway\tasks`). | | `HALLWAY_DEV` | Development mode, for working on Hallway itself (the API runs next to Vite's dev server, as Local Review does). | ## Set by Hallway for managed services <span class="ms ms--shipped">v1</span> Every [managed service](/concepts/services/) and every server started with [`hallway run`](/agents/hallway-run/) gets: | Variable | Value | | --- | --- | | `PORT` | The app's port. | | `HOST` | The app's `host`, by default `127.0.0.1`, so the server isn't exposed to your network. | | `HOSTNAME` | The same, for frameworks that read this instead. | | `PATH` | Built from stable directories, with the service's own Node first. See [the PATH trap](/concepts/services/#the-path-trap). | Anything in the app's `service.env` is added on top. --- # No root in the daemon > Hallway's daemon never runs as root; the only privileged step is one explicit, printed command. **Hallway's daemon never runs as root, and never asks for your password.** It is a single Node process running as you, listening on `127.0.0.1` only. ## What that means in practice - **No sudo anywhere** in normal use. The dashboard, health checks, scanner, bookmarks, managed services, the MCP server, thumbnails and running Hallway as a login service (a LaunchAgent or a systemd user unit) all work as your own user. - <span class="ms ms--shipped">v1</span> **One privileged command, and it shows its work.** Fronted mode needs a few things only root can do: redirecting ports 443 and 80, trusting a certificate authority and, for a custom domain, adding a resolver entry. They all live in [`hallway setup`](/networking/setup-and-doctor/), which **prints each command and why before running it with `sudo`**, has a `--print` mode that runs nothing, and is undone by `hallway teardown`. - <span class="ms ms--shipped">v1</span> **After setup, no more sudo.** Adding, changing or removing an app reconfigures Caddy through its admin API, as your user. - **A short list of places outside the data home**, all named in [The data home](/reference/data-home/#outside-the-data-home). ## The hard part: Caddy and port 443 Serving `https://slides.localhost` means something must answer on `127.0.0.1:443`, which only root may bind. The obvious answer, a root Caddy, has a problem: anything that can reach Caddy's admin API can reconfigure it, so **a root Caddy could be turned against you**, for instance made to serve files only root can read. That would turn any malicious install script into a privilege escalation. So **Caddy never runs as root.** It runs as you, on loopback ports 4443 and 4480, with its admin API on a Unix socket in your data home that only you can open (mode 0600). Since Caddy has no privilege, reaching its API gains nothing. The root parts are fixed and read nothing you could rewrite later: a redirect from 443 and 80 to Caddy's ports (a pf anchor on macOS, an nftables table on Linux), the trusted root certificate, and for a custom domain the resolver entry. See [The security model](/networking/setup-and-doctor/#the-security-model). Rejected: a root Caddy with its admin API on TCP or on a socket only you can open (your own processes could still escalate); a root Caddy with no admin API, reloaded by a privileged helper (sudo for every app change, which defeats the point); and Caddy as you on `0.0.0.0:443`, which would expose every fronted app to everyone on your network. --- # Your ports stay put > Hallway never moves an app to a different port, because apps hardcode their own. **Hallway never reassigns an existing app's port.** Claiming an observed server, declaring an app, running it with `hallway run` or turning it into a service all keep the port it already uses. ## Why Apps hardcode their own ports, in places Hallway can't see: - a build config that bakes in the site's own URL (`site: "http://127.0.0.1:4321"`); - helper scripts and a dev-server proxy that call `http://127.0.0.1:7333`; - other apps, bookmarks and notes that point at the old address. Both of the first two turned up in the incident that started this project. Moving those apps to "tidier" ports would have broken them, so Hallway doesn't. ## What that looks like - **Claim** writes the observed port into the app's file, never a new one. - **MCP** refuses to change an app's `port` at all; change it yourself if you really mean to. - **Two apps on one port** is shown as a warning, not fixed for you (one of them may be stopped on purpose). - **Only brand-new apps get a port assigned**, from `ports.assignRange` (5100–5999 by default, which avoids AirPlay's 5000), skipping ports that are declared, observed listening or well known. The assignment is written into the app's file, so it is stable from then on. - **Vite users:** pass `--strictPort`, or Vite will quietly move itself when its port is busy. See [framework flags](/agents/hallway-run/#framework-flags). --- # Localhost security > How the daemon defends against other web pages, DNS rebinding and cross-site requests. Hallway is local-only: the daemon listens on `127.0.0.1` and nothing is exposed to your network. But "only on localhost" isn't the same as "only reachable by you". Any web page you visit can try to talk to a local server, either through **DNS rebinding** (a hostile domain that resolves to 127.0.0.1) or by sending a cross-site "simple" request. Since the daemon can edit your config, start and stop services and speak MCP, it defends against both, the same way Local Review does. ## The rules - **Loopback only.** The daemon binds `127.0.0.1`, never `0.0.0.0`. - **Host allowlist.** Every request's `Host` header must be `localhost`, `127.0.0.1` or `[::1]` (any port), or the configured dashboard hostname in fronted mode. Anything else gets `403 forbidden host`. This is what defeats DNS rebinding. - **Writes must be JSON.** Every write must be `Content-Type: application/json`, which forces the browser to send a CORS preflight, and **no CORS headers are ever sent**, so the preflight always fails. A `DELETE` has no body, so it is exempt from the content-type rule but not from the next one. - **Origin check.** If a write carries an `Origin` header, it must be one of the allowed hosts. - **The [MCP endpoint](/agents/mcp/) follows the same rules**, as MCP's own guidance for HTTP servers asks: a foreign `Host` or `Origin` gets `403`, and requests must be JSON. There is no login. Anything that runs as you can already edit the data home directly, so a password on the API would add friction without adding protection. ## Why this forces Host rewriting Many local apps, Local Review included, defend themselves the same way, and would answer `403 forbidden host` to a request arriving as `review.localhost`. That is why Hallway's proxy rewrites `Host` to the upstream address by default in fronted mode. See [Host-header rewriting](/networking/fronted-mode/#host-header-rewriting). ## And the rest of the machine - Managed services are started with `HOST=127.0.0.1`, so they stay off the network. - The dashboard flags apps listening on all interfaces as **exposed**, and `hallway doctor` lists them. - The dnsmasq configuration for custom domains binds to loopback only, so it can't become an open resolver. - In fronted mode, Caddy listens on loopback only, runs as you, and its admin API is a private Unix socket, not a port. See [The security model](/networking/setup-and-doctor/#the-security-model). - [Screenshots](/concepts/thumbnails/) (opt-in) are taken by a browser in your data home and never leave your machine. Found a problem? See the repository's security policy on [GitHub](https://github.com/edspencer/hallway). --- # Roadmap > What shipped in which release (v0, v1 and most of v2 are out, plus Windows best effort), what is left, and the acceptance criteria each milestone is checked against. Each milestone is useful on its own, and each is checked by **running the software** against the criteria below, not by reading the code. :::note[Where things stand] **v0 and v1 have shipped, and so has all of v2's software**, for macOS and Linux. What is left is v2's last criterion: running everything on a real Mac. Fronted mode has run for real on Linux and on CI's macOS runners, but no browser has been driven on a Mac yet, and nothing has been checked across a real reboot or a macOS update. **Windows** is supported where feasible, best effort, since 0.4.0 and 0.5.0: everything but fronted mode. It has run only on GitHub's hosted Windows runners, not yet on a real Windows desktop. See [Windows](#windows) below. The [specification](https://github.com/edspencer/hallway/blob/main/spec.md) in the repository is the source of truth. ::: ## Releases | Release | What it shipped | | --- | --- | | **0.1.0** | All of v0: the daemon, the dashboard, health and uptime, the scanner, bookmarks, the agent guide, `hallway doctor`. From v1, early: [`hallway run`](/agents/hallway-run/), `stop`, `start`, `restart` and `logs`, managed services for apps and log rotation. | | **0.2.0** | The rest of v1's networking: [fronted mode](/networking/fronted-mode/) (`hallway setup` and `teardown`, Caddy, `https://<app>.localhost`), [custom domains](/networking/custom-domains/), the fallback page and doctor's networking checks. From v2: the [MCP server](/agents/mcp/) and the [dev-server hook](/agents/hook/). | | **0.3.0** | The last of v1: [Lost at last reboot](/concepts/services/#lost-at-last-reboot). From v2: [screenshot thumbnails and generated cards](/concepts/thumbnails/), and a fast 90-day uptime view. | | **0.3.1** | A fix: `hallway lost dismiss --all` was refused as an unknown option. | | **0.4.0** | [Windows](#windows), best effort: the daemon, dashboard, health checks, bookmarks, MCP and the CLI's file commands run natively, and the [port scanner](/concepts/apps/#observed-listeners-the-scanner) finds Windows' listeners with each one's directory, project and branch, without administrator rights. `hallway setup` refuses on Windows: fronted mode is out of scope there. On every OS, a listener whose directory can't be read shows its command line instead. | | **0.5.0** | Windows managed services: `hallway service install` and [`hallway run`](/agents/hallway-run/) work there, as [per-user scheduled tasks](/concepts/services/#windows) that start at logon. | ## v0: The front page <span class="ms ms--v0">v0</span> **Released in 0.1.0.** **Scope:** the daemon; the dashboard; YAML config with live updates; health probes with state and 24-hour history; the scanner with identity, claim and ignore; bookmarks; favicon tiles; the agent guide and `llms.txt`; `hallway service install` for the daemon itself; `hallway doctor` for the daemon and data. **Direct mode only. No sudo anywhere.** **Platforms:** macOS and Linux. On macOS the daemon is a LaunchAgent and the scanner uses `lsof`; on Linux it is a systemd user unit (with lingering enabled if it should run without a login session) and the scanner uses `ss` or `/proc`. Every criterion below was checked on Linux by running it. The macOS side is unit-tested and CI runs the real launchd service job on a macOS runner. Criterion 1's "after logging out and in, and after a reboot" has not been run on either OS. Done when: 1. `npm i -g hallway && hallway service install` serves the dashboard at `http://localhost:4255`, still serving after logging out and in, and after a reboot (a LaunchAgent on macOS; a systemd user unit on Linux). 2. Creating `apps/foo.yaml` with a `name` and `port` in an editor makes a card appear on an open dashboard within a second, without a reload. Editing and deleting the file are reflected the same way. 3. A syntactically broken `apps/foo.yaml` shows an error on that card (file, line, message) while every other card keeps working; the daemon doesn't crash; the file isn't modified. 4. Starting `python3 -m http.server 4567` in some directory makes it appear in the observed tray within one scan, labelled with that directory; **Claim** writes `apps/<slug>.yaml` with `port: 4567` and the card moves into Apps. 5. IDE and agent-tool helpers on ephemeral ports don't appear in the observed tray. 6. Stopping a server declared `expected: running` turns it `down` after two probe intervals; one declared `expected: adhoc` turns `stopped` instead. 7. Two worktrees of one repo running the same app on different ports show as `repo @ branch-a` and `repo @ branch-b`. 8. Bookmarks render as favicon chips in their groups, and editing a file updates the page live. 9. `/` focuses the filter; typing a unique match and pressing Enter opens it. 10. A request with `Host: evil.example` gets 403; a cross-origin `POST` gets 403 or 415. 11. `curl localhost:4255/llms.txt` returns the agent guide summary with the live data-home path and app list. 12. The page fully renders in under 200 ms from a warm daemon. ## v1: Fronting and services <span class="ms ms--shipped">v1</span> **Released**: the services half in 0.1.0, fronted mode and custom domains in 0.2.0, "Lost at last reboot" in 0.3.0. **Scope:** `hallway run`, `stop`, `start`, `restart` and `logs`; managed services for apps (launchd on macOS, systemd user units on Linux); Caddy through its admin API; fronted mode on `*.localhost` with HTTPS; Host rewriting; the fallback page; `hallway setup` and `teardown`; networking checks in `hallway doctor`; custom domains (dnsmasq, and `/etc/resolver` on macOS or a `systemd-resolved` drop-in on Linux); the "exposed" indicator; "lost at last reboot"; log rotation. **How it was checked:** criteria 1–4 and 7–11 run for real on Linux and on CI's macOS runners (setup with real `sudo`, a squatter on 443, a custom domain, teardown leaving nothing behind), with `curl` and the system trust store standing in for a browser. "Lost at last reboot" was checked on Linux with a simulated reboot. **Not yet checked:** Chrome, Safari and Firefox on a real Mac (criterion 2), and anything across a real reboot (criterion 5's reboot half). Done when: 1. `hallway setup --print` prints every privileged command with an explanation and runs nothing; `hallway setup` runs them through `sudo`, and is safe to re-run. 2. After setup, `https://hallway.localhost` shows the dashboard with a trusted certificate in Chrome and Safari (and Firefox after the documented setting). 3. Declaring an app on port 5622 as `review` makes `https://review.localhost` work **with no sudo and no restart**, and an app that checks its `Host` header doesn't answer `403 forbidden host`. 4. A Vite or Astro dev server fronted at `https://<slug>.localhost` has working hot reload. 5. `hallway run demo -- npx vite --port $PORT --strictPort` returns within 30 seconds printing the URL; closing the terminal doesn't stop the server; after a reboot it is `stopped` and listed under "Lost at last reboot"; with `--persist` it is running again after a reboot. 6. A generated job's `PATH` starts with the resolved Node's `bin` directory and contains no per-shell or missing directories; `hallway doctor` flags a job that does. 7. Stopping the daemon makes `https://hallway.localhost` serve the fallback page, listing every app with working links. 8. With something else holding port 443, `hallway doctor` names the process and PID and suggests a fix, and `hallway setup` stops instead of fighting it. 9. `hallway setup --domain myname.internal` makes `https://myname.internal` and `https://review.myname.internal` work, with dnsmasq bound to loopback only. With a made-up TLD, `hallway doctor` warns and checks IANA's list. 10. `hallway setup --domain foo.local` is refused, with an explanation of the mDNS conflict. 11. `hallway teardown` leaves no LaunchDaemons, resolver file, drop-ins or trusted root behind, and the dashboard keeps working in direct mode. 12. A managed app writing 50 MB of logs ends up with rotated files no larger than the limit. ## v2: Agents and polish <span class="ms ms--shipped">v2</span> **Released, except criterion 5**: the MCP server and the hook in 0.2.0; screenshot thumbnails, generated cards and the fast 90-day view in 0.3.0. **Scope:** the [MCP server](/agents/mcp/) (Streamable HTTP at `/mcp`, and `hallway mcp` over stdio); [`hallway hook check-command`](/agents/hook/); optional [screenshot thumbnails](/concepts/thumbnails/) (`hallway thumbs enable`); generated-card thumbnails (`thumbnails.mode: card`); 7- and 90-day uptime from hourly rollups, with a latency chart. Linux support moved forward to v0 and v1. Done when: 1. An MCP client can `add_bookmark` and the dashboard shows it live, with the YAML file's comments preserved. (Met.) 2. `declare_app` with a `service` block is rejected; no MCP tool can start a process. (Met.) 3. `hallway thumbs enable` downloads a browser into the data home and healthy apps get screenshots; `hallway thumbs disable` removes it; the base install never depends on it. (Met.) 4. The 90-day view renders from rollups only and stays fast with 30 apps. (Met.) 5. Every v0 and v1 criterion has been run on a real Mac, with nothing left marked unverified on macOS. **(Not yet.)** ## Windows **Supported where feasible, best effort** (since 0.4.0). macOS comes first and Linux sits alongside it; Windows comes third. Hallway runs natively there (not through WSL) and never asks for administrator rights. - **What works:** the daemon, the dashboard, health checks and uptime, bookmarks, the [port scanner](/concepts/apps/#observed-listeners-the-scanner) (0.4.0), MCP, the agent guide, the CLI, and [managed services](/concepts/services/#windows) as per-user scheduled tasks that start at logon (0.5.0). - **What doesn't:** [fronted mode](/networking/fronted-mode/). Its parts (the 443/80 redirect, trusting a root certificate, a custom domain's resolver) have no user-level equivalent on Windows, so `hallway setup` refuses and apps keep their `http://localhost:<port>` addresses. Services don't run while you're logged off: Linux's lingering has no Windows equivalent without a stored password or administrator rights. - **How it was checked:** unit tests, `smoke:pack`, the scanner (as an administrator and as a standard user) and real scheduled tasks all run in CI on GitHub's `windows-latest` runners. **Not yet checked on a real Windows desktop:** a real logon starting the tasks and a logoff stopping them, no console window flashing at logon, a standard user registering tasks, an interactive user's dev servers, `hallway open`, and non-English Windows. ## What is left - **A run on a real Mac** (v2 criterion 5). Still unverified there: Safari, Chrome and Firefox accepting Hallway's certificate, and Safari resolving `*.localhost`; services and the daemon coming back after a logout or a reboot (`RunAtLoad`), and "Lost at last reboot" after a real one; the pf redirect surviving another tool reloading `/etc/pf.conf`, and a macOS update. On Linux, a real reboot (with lingering on) hasn't been run either. - **A run on a real Windows desktop**, for the [Windows](#windows) items above. - **Service controls in the dashboard.** The detail drawer doesn't yet have start, stop and restart buttons or a live log tail; the CLI and the [HTTP API](/reference/http-api/) do. - **Discovery adapters.** The interface for reading another tool's registry exists, but no adapter ships yet (see [Apps](/concepts/apps/#discovered-apps)). ## Later, unscheduled - **Lazy start:** start a stopped service on the first request to its hostname. - macOS notifications when an `expected: running` app goes down. - Opt-in health checks for remote bookmarks. - Docker and process-compose discovery adapters. - A Homebrew tap. - Importing bookmarks from Homepage, Dashy or Homer configs. - Several base domains at once. ## Still open A few decisions are still open, among them whether a short `hw` alias ships alongside `hallway`, and the final visual identity (the hallway look is the working direction). The full list is in the repository's [open questions](https://github.com/edspencer/hallway/blob/main/docs/10-open-questions.md). --- # Why the name > How Spaghetti Junction became Hallway, the names considered on the way, and the corridors behind the design. Hallway started life with a different name, and a different picture in mind. ## It began as Spaghetti Junction The first mental image was the mess itself: a machine knotted with servers on random ports, which the tool would untangle. **Spaghetti Junction** is the popular name of the Gravelly Hill Interchange, junction 6 of the M6 in Birmingham, England, a famously tangled knot of flyovers and slip roads, proverbially the most complicated road junction in Britain. A reverse proxy is a kind of junction too, so the name described the problem and the function at once. It came with a visual identity to match: British motorway signage, all route numbers and direction signs. The package would have been `spaghetti-junction`, and the command `sj`. ## Why it was renamed In October 2026, before any code was written, the project was renamed. The old name was: - **too long, and hyphenated**: a single word was wanted; - **cryptic at the command line**: `sj` meant nothing to anyone who hadn't heard the story; - **about the wrong thing**: it named the mess, not the tidy result you actually want. ## Names considered The original naming round had looked at pasta and roads: `colander`, `orzo`, `noodle`, `skein`, `tangle`, `untangle`, `switchboard`, `interchange`, `porthole` and more, nearly all already taken on npm. (`aldente` was free, but shares its name with a well-known macOS battery utility.) The rename went looking for words about arriving, gathering and finding your way: `porter`, `portico`, `concierge`, `foyer`, `lobby`, `wrangler`, `muster`, `flyover`, `slipway`, `signpost`, `fingerpost` and `beacon` were all taken. The free shortlist was `strays`, `pinfold`, `foundling`, `digory`, `otherwood`, `kenning`, `dewpond`, `gyratory` and `sliproad`. **Hallway** beat them all because it was the most ordinary word on the list, and because it extended a phrase the project already used about itself: *the front door for everything you vibe-coded on a random port*. If the product is the front door, the dashboard is the hallway behind it, the place where all the doors are. It is plain, easy to say and to spell, one word, and it describes the cure (a calm, tidy home where you know what is behind every door) rather than the disease. The default port followed: **4255** spells HALL on a phone keypad. ## The hallway The design follows the name. The dashboard is a calm, well-kept hallway at night, lit by warm lamps. Each app is a **door**, and its port is the **door number** on a brass plate. A door's state shows the app's health: **ajar**, with a sliver of light, means healthy; **closed** means stopped on purpose; **dark, or boarded up**, means down. Servers the scanner found but nobody has named are **noises behind doors you didn't know were there**. Bookmarks to remote places hang on a **key hook by the front door**. And after a reboot, the doors that closed while you were away are listed, each with a way to open it again. The feeling to aim for is *coming home and knowing where everything is*: not a control room, not a server rack, and not a haunted house. ## Corridors of doors Three imagined places shaped that picture. Hallway borrows their feeling, never their art. **L-space.** In Terry Pratchett's Discworld novels, every library is connected to every other library, because books bend space. Hallway's corridor likewise connects every place you work: local apps through its doors, remote ones through the bookmarks by the front door. **The corridor of doors in _Dark Matter_.** In the Apple TV+ series (2024), a box opens onto an endless corridor of identical doors, each leading to a different version of the world, and the whole difficulty is that they all look the same. That is a warning as much as an image: Hallway's doors must be *distinguishable* at a glance, each with its own paint, number, name and `repo @ branch`, never a grid of identical rectangles. **The Wood between the Worlds.** In C. S. Lewis's _The Magician's Nephew_, the prequel to the other Narnia books, two children find a drowsy, quiet wood full of identical pools. Each pool leads to a different world, and the place is so sleepy that you can forget which pool is home, so they mark theirs before they leave. That is the original problem exactly: a machine full of identical-looking dev servers, and nobody remembering which is which. Hallway's answer is the same as theirs: label every door. :::note[Only a story] These references are the backstory, not the brand. Hallway uses none of their imagery, characters, typography or logos, and the design must make sense to someone who recognises none of them: it is a hallway first. _Narnia_ and _Aslan_ are trademarks of their owners and appear here only to tell this story. ::: ## The logo The arched door left ajar, with lamplight spilling across the floor, is a **placeholder** drawn for this site while the hallway identity is the working direction. It will be replaced when the identity is finalised. --- # Contributing > How the project is organised, how to help while it is young, and how to work on this site. Hallway is open source and developed in the open at [github.com/edspencer/hallway](https://github.com/edspencer/hallway). ## Where things stand The project began as an exhaustive specification, written so that someone who wasn't part of the design discussion could build it. v0 and v1 are built and released, and so is v2 apart from its run on a real Mac (see the [roadmap](/roadmap/)). Where code exists, **the code and the decision log are authoritative**, and the rest of the spec explains why: - [`spec.md`](https://github.com/edspencer/hallway/blob/main/spec.md): the summary, document map, key decisions, glossary and milestones; - [`docs/`](https://github.com/edspencer/hallway/tree/main/docs): the background, architecture, networking, apps and health, the dashboard, the agent interface, the CLI, the roadmap, the decision log, the open questions and the research notes, in that order. ## Ways to help - **Try the ideas against your own machine.** Does the scanner's filtering match what you see in `lsof -nP -iTCP -sTCP:LISTEN`? Is there a kind of server it would miss or mislabel? Open an issue. - **Verify the unverified, especially on a Mac.** The [open questions](https://github.com/edspencer/hallway/blob/main/docs/10-open-questions.md) and the research notes' "unverified on macOS" list name what still needs checking by experiment: whether Safari resolves `*.localhost`, whether Safari, Chrome and Firefox trust the certificate after `hallway setup`, whether services come back after a logout or a reboot, and whether the pf redirect survives a macOS update. - **Disagree in writing.** If you think a decision is wrong, record your alternative and the reasoning in an issue or a PR to `docs/09-decisions.md`, rather than quietly building something different. ## Ground rules - **Never require root in the daemon.** Privileged steps belong only in `hallway setup` and `hallway teardown`, and are printed before they run. - **Keep it generic.** Examples, defaults and docs must not assume any particular employer, machine, username or network: use `example.com`, `myname.internal`, `~/Code/my-app` and similar. - **Build in milestone order**, and check each milestone by running it. - **The dashboard's visual design is core scope**, not polish. The project follows the conventions of its sibling, [Local Review](https://github.com/edspencer/local-review): the stack, the data home layout, live updates, security middleware, the agent guide and the release process. ## Working on this site This site lives in the repository's `website/` directory: [Astro](https://astro.build) and [Starlight](https://starlight.astro.build), built and deployed on its own, separate from the app. ```sh cd website npm install npm run dev # local dev server npm run build && npm run check-links # what CI runs ``` Pages are Markdown under `website/src/content/docs/`, and the sidebar is maintained by hand in `website/src/sidebar.mjs`. Every feature a page describes is marked with the milestone it belongs to: lamplit once it has shipped (<span class="ms ms--v0">v0</span>, <span class="ms ms--shipped">v1</span>, <span class="ms ms--shipped">v2</span>), grey until then (<span class="ms">v2</span>). See `website/README.md` for the rest.