# 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/.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** v0 | A file, `apps/.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** v0 | 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, `` 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 ``
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/.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 # 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 v0. 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 |
| --- | --- | --- | --- |
| ● healthy | Responding as expected | Ajar, a sliver of warm light showing | Calm, positive |
| ▲ unhealthy | Listening but failing: HTTP 5xx, the health path not answering as expected, or a timeout | Light flickering behind it | Warning |
| ■ down | Should be running, and is not listening | Dark, boarded up | **Alarm: the only red state** |
| ○ stopped | Not listening, and that is fine | Closed | Neutral, quiet |
| ◌ unknown | Not probed yet | In shadow | Neutral |
A [managed service](/concepts/services/) that has just been started shows a short-lived
◐ starting 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 `. 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/`, 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.
- v0 The last **24 hours** at full resolution, drawn on every card as 48 bars of 30
minutes each.
- v0 **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
v0 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** v0, the default | **Fronted mode** v1, optional |
| --- | --- | --- |
| App URLs | `http://localhost:` | `https://.` |
| 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:`. 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 `.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:` 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
v0 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
v1 An app with a `service:` block is a **managed service**: Hallway generates a job for it,
`~/Library/LaunchAgents/hallway.app..plist` on macOS, `~/.config/systemd/user/hallway-app-.service` on
Linux, or the scheduled task `\hallway.app.` 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.`, 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-.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
v1 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.`). 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/_/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/.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
v1 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/.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 # start one again, once
hallway lost service # start it again, as a service
hallway lost dismiss # 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 ◐ starting 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.
v0 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/.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 (v0, 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.
v2 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: ● healthy,
▲ unhealthy, ■ down,
○ stopped, ◌ unknown.
- **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
v0 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
v2 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 )
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
v2 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://.localhost, and why it rewrites the Host header.
v1 In fronted mode, [Caddy](https://caddyserver.com) serves each app at
`https://.`, by default `https://.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:` 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: `.`, 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.
v1 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:` addresses.
:::
## `hallway setup`
v1 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/` 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/` | `/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`
v1 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.
**v0 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)).
**v1 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 `` 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
v0 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/.yaml` (a `name` and a `port` is enough);
- **add bookmarks** by editing `bookmarks/.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. v1 **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. v0 **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. v2 **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
v2 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.
v1 `hallway run` replaces the ad-hoc `npm run dev` that leaves orphaned servers behind.
```sh
hallway run [--cwd DIR] [--port N] [--persist] [--name "Display name"] --
```
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/.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 ` 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
v0 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 -- `, and why, with the
framework flags.
3. How to register an existing server: the minimal `apps/.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
v0 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 -- `, which keeps it running outside this
> session on a stable port and prints its URL. Run `hallway guide` for details.
## The optional hook
v2 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.
v2 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:///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/.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/.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/` | 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.
v2 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:`, 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 " line. | v0 |
| `hallway open` | Open the dashboard in your browser (`https://hallway.localhost`, or your domain's, in fronted mode). | v0 |
| `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. | v0 |
| `hallway service uninstall` | Unload and remove it. | v0 |
| `hallway service status [--json]` | Whether the daemon's job is loaded and running, and its last exit code. | v0 |
| `hallway service restart` | Restart the daemon's service. | v0 |
| `hallway --version`, `hallway --help` | | v0 |
## 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. | v0 |
| `hallway scan [--json]` | Look for listeners nobody has named, and print them with identity. | v0 |
| `hallway claim [--slug S] [--name N] [--expected E]` | Turn an observed listener into a declared app. | v0 |
| `hallway ignore [--by port\|process\|dir]` | Hide an observed listener. | v0 |
| `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). | v1 |
| `hallway lost restart\|service [--slug S]` | Start one again: once, or as a service, the way `hallway run` would. Needs the daemon. | v1 |
| `hallway lost dismiss ` / `hallway lost dismiss --all` | Clear one, or the whole notice. | v1 |
| `hallway app add [--port N] [--name N] [--expected E] [--health PATH]` | Declare an app (writes `apps/.yaml`); without `--port` it gets a free port from the assignment range. | v0 |
| `hallway app ls [--json]` | List the declared apps. | v0 |
| `hallway app edit ` | Open the app's YAML in `$VISUAL` or `$EDITOR`. | v0 |
| `hallway app rm ` | Remove an app: its file moves to `backups/removed/`, and its service is removed first if it has one. | v0 |
## Running servers
| Command | Description | |
| --- | --- | --- |
| `hallway run [--cwd DIR] [--port N] [--persist] [--name N] -- ` | 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/). | v1 |
| `hallway stop ` / `start ` / `restart ` | Stop, start or restart a managed app. | v1 |
| `hallway logs [--lines N] [--follow]` | Tail a managed app's logs. | v1 |
| `hallway service add [-- ]` | Keep a declared app running: at login, and restarted if it exits. | v1 |
| `hallway service rm ` | Stop managing it; the app stays declared. | v1 |
| `hallway service regenerate [slug]` | Rewrite the generated services, e.g. after a Node upgrade broke an absolute path. | v1 |
## Bookmarks
| Command | Description | |
| --- | --- | --- |
| `hallway bookmark add [--title T] [--description D]` | Add a link to a group (creating the group if needed). | v0 |
| `hallway bookmark ls [group] [--json]` | List groups and links. | v0 |
| `hallway bookmark rm ` | Remove a link, by its URL or its position in the list. | v0 |
| `hallway bookmark edit ` | Open the group's YAML in `$VISUAL` or `$EDITOR`. | v0 |
## 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://.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/). | v1 |
| `hallway teardown [--uninstall-packages] [--print]` | Undo setup and go back to `http://localhost:` links. `--uninstall-packages` also uninstalls Homebrew's Caddy and dnsmasq. | v1 |
## 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`). | v0 |
| `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/). | v2 |
| `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/). | v2 |
## 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. | v2 |
| `hallway thumbs disable` | Turn them off: sets `thumbnails.mode: favicon` and deletes the browser and the screenshots. | v2 |
| `hallway thumbs refresh [slug]` | Take new screenshots now, of one app or every healthy one. Needs the daemon, which does the shooting. | v2 |
| `hallway thumbs status` | The mode, whether a browser is installed and how big it is, and how many screenshots there are. | v2 |
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). | v0; fronted-mode checks v1 |
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 .
dashboard: hallway.localhost # where the dashboard lives in fronted mode.
# Default: "hallway.localhost" when base is "localhost";
# the bare apex "" 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:`; `fronted` serves apps through Caddy at `https://.`. Set by `hallway setup` and `hallway teardown`, rather than by hand. See [modes](/concepts/modes/). v1 |
| `domain.base` | `localhost` | Base domain for fronted mode. See [Custom domains](/networking/custom-domains/). v1 |
| `domain.dashboard` | `hallway.localhost`, or `` | The dashboard's hostname in fronted mode. Its first label is then a reserved slug. v1 |
| `domain.tls` | `true` | Fronted mode is always HTTPS. v1 |
| `domain.dnsPort` | `4253` | Custom domains only: the loopback port Hallway's own dnsmasq answers on; the resolver entry points at it. v1 |
| `proxy.caddy` | `caddy` on your `PATH`, else Homebrew's | The Caddy binary (2.7 or later). v1 |
| `proxy.httpsPort` | `4443` | The loopback port Caddy, running as you, serves HTTPS on. v1 |
| `proxy.httpPort` | `4480` | The loopback port Caddy serves HTTP on (a redirect to HTTPS). v1 |
| `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). v1 |
| `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. v1 |
| `services.logMaxBytes` | `10485760` | Rotate a service's log above this size (10 MB). v1 |
| `services.logGenerations` | `3` | Rotated logs to keep. v1 |
| `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/). v2 |
| `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/.yaml
> The format of a declared app - one file per app, named after its slug.
Each declared app is one file, `~/.hallway/apps/.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.
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). v1 |
| `proxy.aliases` | | Extra hostname labels for the app: `[deck]` also serves it at `deck.`. v1 |
| `service` | | Makes the app a [managed service](/concepts/services/#managed-services). Write it with `hallway run` or `hallway service add` rather than by hand. v1 |
| `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/.yaml and ignore.yaml.
## `bookmarks/.yaml`
v0 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`
v0 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/
.yaml # one declared app per file (yours to edit)
bookmarks/
.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/.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
.out.log, .err.log # managed services
backups/
/ # 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:
- v0 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;
- v1 one per managed service: `~/Library/LaunchAgents/hallway.app..plist`
on macOS, `~/.config/systemd/user/hallway-app-.service` on Linux, `\hallway.app.` on Windows;
- v1 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.
- v2 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/.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
v1 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.
- v1 **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`.
- v1 **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://.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
v0 **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/.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
v1 **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://.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
v2 **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:` 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 (v0, v1,
v2), grey until then (v2). See `website/README.md` for
the rest.