Architecture
On this page
Short Leash.app (Tauri, React) ─┐
leash CLI ────────────────┼── HTTP over a unix socket ──▶ leashd (runs as you)
leash mcp (stdio MCP) ────┘ ├─ proxy 127.0.0.1:80/443, TLS by SNI
│ ├─ static files
│ ├─ PHP → FastCGI → php-fpm pool per version (+ Xdebug pool)
│ ├─ Python → granian per site
│ ├─ dev servers and services → their port
│ └─ share listener for cloudflared
├─ DNS 127.0.0.1:5354, *.test → 127.0.0.1
├─ CA name-constrained to .test
├─ supervisor: PHP, Python, dev servers, processes, services, cloudflared
├─ requests in-memory ring buffer per site, traffic per minute
├─ dumps loopback collector fed by a PHP prepend file
├─ terminals your shell in a PTY per site terminal
├─ scratchpads code run inside a site, a process per run
├─ tasks a task list per site
├─ notifications rules over the above, kept in memory
├─ store SQLite: deliveries, tasks, terminals, scratchpads, traffic counts
└─ engine config, sites, runtimes, services, sharing
Processes
- leashd is a launchd job (
dev.shortleash.daemon) that runs as the logged-in user. launchd opens127.0.0.1:80and:443and passes the sockets to it. It serves an HTTP API on~/Library/Application Support/Short Leash/run/leashd.sock. Only one leashd runs at a time. Before setup the app starts leashd itself on fallback ports. When launchd's leashd starts and finds another daemon answering on the socket, it asks that one to stop and takes over. Any other leashd that finds one already running exits. - Short Leash.app is the windows and menu bar icon. It holds no Short Leash state: everything it shows comes from leashd, and it gets updates as server-sent events. It keeps only how its windows are laid out: panels docked in the main window, floating panel windows (
panel-<id>, each its own web view), and the main window's frame (see ADR 0031). - leash is the CLI, the MCP server, and (as
phpandcomposersymlinks) the terminal shims. Each is a thin client of the same API.
A request to https://shop.test
- The browser asks the system resolver for
shop.test./etc/resolver/testsends the query to leashd's DNS server, which answers127.0.0.1. - The browser connects to
127.0.0.1:443. leashd picks the certificate forshop.testby SNI, issuing it from the local CA the first time. - The proxy records the request, gives it its id in an
X-Leash-Requestheader (replacing any the visitor sent; see ADR 0051), and routes it by site type: static files from disk, PHP through FastCGI to the pool for the site's version, Python and dev servers by proxying to their port, services by proxying to theirs. - PHP pools, Python workers and dev servers start on the first request and are supervised from then on.
Shared requests
cloudflared connects out to Cloudflare and forwards visitors to a separate share listener on loopback. Only that listener treats requests as HTTPS from the outside: it checks the password and IP allowlist, sets the public host for PHP, and rewrites .test addresses in redirects to the public one. Webhooks run their own tunnels into the same listener. Their deliveries skip the password, have their signatures checked by webhooks::pipeline, and are forwarded to their sites as local requests through the proxy. Regular .test traffic never trusts forwarded headers.
Terminals, scratchpads, tasks and notifications
- Terminals are pseudo-terminals leashd opens (
leash_core::terminals, ConPTY on Windows) with your login shell in the site's folder and the environment the site's processes get. A reader thread per terminal hands output to a task that sends it on about once a frame as a numberedterminalOutputevent, keeping the last 1 MiB for replay. Details are saved in the database and the end of the output underterminals/; both come back as stopped after a restart. See ADR 0026. - Scratchpads run code inside a site with a new process per run (
leash_core::scratchpads,engine::scratchpads): the site's runtime in its folder with its environment, booting the app through a driver picked from the site's type. A small runner per language (PHP, Python, Ruby, a support module for Node, Bun and Deno, a generatedmainpackage for Go) writes what happened to an events file, one JSON object per line; the PHP dumps prepend writesdump()and Laravel's queries there too. leashd reads that file and the process's output about every 50 ms and sends numberedscratchpadRunevents. Stop and the 60-second limit kill the run's process group. See ADR 0030. - Calls (
leash_core::calls,engine::calls) are requests saved per site and sent by leashd. One to a Short Leash site is handed to the proxy in process with an id it picks, so it's recorded on Requests and its dumps carry that id; anything else goes out with reqwest, one redirect at a time. Checks and kept values are rules evaluated in Rust; no script runs. With Keep in project folder on, the site's calls are also YAML files in the project, read on first use and when the app comes back to the front, written after each change. See ADR 0050. - Tasks are rows in the database. A claim is a lease with an end time, taken in one transaction; an expired claim is simply ignored. A rename is one
UPDATEand unlinking oneDELETE. - Notifications are raised by rules in the engine from what it already watches (supervisor states, terminal attention, mail, webhook deliveries, dumps, shares and busy ports), worded in
notifications::rules, folded when they repeat, and sent asnotificationevents. The app shows desktop notifications and the unread count; it decides nothing. See ADR 0027. - The site overview (
GET /v1/sites/{name}/overview) puts together the site's traffic for the last hour (from the request recorder, whose per-minute counts are saved so they outlive a restart), slow queries (from dumps), its git branch, what it's built with, the services its.envpoints at and checks such as pending migrations. git, artisan and Composer run with time limits off the async runtime, and their answers are reused for a minute. Short Leash's own artisan runs (migrate:status, route lists) go without Short Leash's prepend and with Xdebug off, so Dumps and the Monitor never take them for the app's. Home asks with?quick=true, which never runs artisan and shows the pending migrations the site's Overview last found. The PHP extensions check (alsoGET /v1/sites/{name}/extensionsand thecheck_extensionsMCP tool) compares whatcomposer.jsonandcomposer.lockneed with what the site's PHP version prints forphp -m, reused for a minute too, or with the lists in code when that version isn't installed.
What's kept where
config.tomlholds your settings, andsecrets/*.jsonthe share password, Cloudflare tokens, webhook signing secrets and the calls' secret values and tokens. Both are files, written in one step through a temporary file.- Short Leash's database,
db/leash.db, holds webhook deliveries, task lists, terminal details, scratchpads with their last runs' code, calls with their folders, environments and kept examples, and per-minute request counts (leash_core::store). Only leashd opens it, through one connection on its own thread. Each table has a limit or an age. Webhook bodies over 64 KiB are files indb/blobs/. When the database can't be opened, leashd still starts, keeping deliveries in memory and tasks and terminals in files. See ADR 0029. - The app's
layout.json, in its own config folder (~/Library/Application Support/dev.shortleash.appon macOS), holds the panels layout and window frames. Only the app reads it; leashd never does. - Memory holds requests, dumps, process logs, notifications, what scratchpad runs produced, and calls' history and cookies, which go when leashd restarts.
Code
| Crate | What's in it |
|---|---|
leash-core |
Everything: config, sites and detection, proxy, DNS, CA, supervisor, runtimes, services, sharing, dumps, requests, terminals, tasks, notifications, the database, the API |
leashd |
Starts the core subsystems and serves the API |
leash-cli |
The leash command, MCP server and shims |
src-tauri |
The desktop shell: windows, tray, the panels layout and its floating windows, OS integration; no domain logic |