Services
A service is a set of scripts the daemon keeps running for you. You describe it once, as commands or as a file, and from then on the daemon owns it: it survives your terminal closing, it restarts by a policy you pick, it can come up on boot, and it serves the scripts' screens and logs on HTTP and WebSocket routes. yeet run --detach keeps one script alive past your terminal; an enabled service keeps a whole set alive past a reboot.
yeet service new pulse -C . # a service, empty, whose project is this directory
yeet service unit add pulse/app -I ./pulse.js # a script in it
yeet service unit add pulse/web -W http://127.0.0.1:9100 # a web server in it
yeet service mount pulse/web -L /log -t app -p console # a route from the one to the other
yeet service start pulse # bring it up
yeet service tree # see all of it
Run yeet service --help or yeet service <command> --help for the full option list. The help text is kept current with the binary and is the source of truth when it disagrees with this page.
What a service adds to yeet run
yeet run is one script, one isolate, one terminal. A service is where the daemon, not your shell, is the thing holding the scripts, and that buys five things a run cannot:
- Several scripts as one thing. A service groups any number of isolates and web servers under one name, and
start,stop,restart,export, andremoveact on all of them together. Isolates in the same service share a scope: aSharedWorkeropened by one unit is the same worker for every other unit of that service, which is how a long-lived collector and a per-request scrape script share one registry. - A life of its own.
run --detachoutlives your terminal; an enabled service also outlives a reboot, and a restart policy brings a unit back when it goes down. - Routes instead of redirects.
run -pserves one script's tty or console on one address. A web-server unit serves many routes on one port: several isolates, each portal, a static directory, an upstream, each with its own tenancy and cap. - Isolates on demand. A lazy unit is a script the daemon runs only when a route asks, and per-connection tenancy runs a fresh copy per client, so a script can be a request handler rather than a process.
- A file.
exportwrites the whole service as TOML or JSON andimportbuilds it again, on this host or another. - A copy of its own. A service runs its own copy of the project its scripts come from, so a
git pull, an edit, or a deleted checkout does not reach it until you build it again. While you are working on it,--devruns the checkout in place instead; see the dev loop.
The pieces
A service has three kinds of parts, and yeet service tree draws them as a tree:
● noteworthy_shoulder (pulse) running restart=no (disabled)
├─app isolate id=3705
├─web web-server http://127.0.0.1:9100
│ ├─GET / => /home/you/pulse/public
│ ├─GET /log console@app
│ ├─GET /snapshot console@snapshot per-connection one line per request
│ └─GET /tty app
└─snapshot isolate --host docs (lazy)
-
The service is the root line. Every service has an id the daemon mints, two words joined by an underscore, and optionally an alias you choose, shown in parentheses. Every command that takes a service accepts either. The line also shows the service's status, its restart policy,
(disabled)when it will not start on boot,(dev)when it runs its checkout in place, and(ephemeral)when stopping it removes it. A service also has a working directory, the directory its scripts, what they import, and its static files live under; see where a service runs its scripts from. -
Units are the children, and they are the things the daemon starts and stops. A unit is a description, not a process: the service remembers it while stopped, and
startturns it into something running. There are two kinds, and a unit is one or the other for life.- An isolate unit is one script and how to run it: its
yeet.args, its heap budget, and whether it is eager (launched with the service) or lazy (launched by a route on first demand). When it runs, it is an ordinary isolate with an id, visible inyeet psand reachable withyeet attach -c. One unit can stand behind several running isolates at once, when a per-connection route spawns a copy per client. - A web-server unit is one listening address,
http://orws://on a literal IP address and port, and the routes mounted on it. It runs nothing by itself; it is how clients reach the isolate units beside it. It answers only once the host is signed in.
A unit is named
SERVICE/NAME, such aspulse/app, and units in one service must have distinct names whatever their kind. - An isolate unit is one script and how to run it: its
-
Routes hang off a web server. A route answers one path with one method, and serves one of three things: an isolate unit's portal (its
ttyor itsconsole, the same two channelsyeet run -predirects) under a tenancy; the files beneath a directory; or a WebSocket upstream, aws://orwss://URL elsewhere that the client's WebSocket is forwarded to, so a service can put a listener that is not yeet behind one of its paths.
What a client gets from a portal route is what yeet run -p would serve: a tty over WebSocket speaks the tty protocol, a console over WebSocket is one text frame per message, and a console over plain HTTP GET is a chunked text/plain body, one message per line, that stays open until the isolate ends or the client leaves. A static route serves the files below its path: mounted at /assets, it answers /assets, /assets/, and /assets/index.html with the directory's index.html, /assets/nope and a .. climb with a 404.
A route matches its path and everything below it, case-sensitively: a route at /log answers /log, /log/, and /log/extra, and /LOG is a 404. A service can have several web servers, each on its own address, and any route can target any isolate unit of the service. A web server bound to 127.0.0.1 answers this host only; bind 0.0.0.0 to answer other machines.
Sign-in
A web-server unit serves its routes only while the host is signed in, by yeet login, by a YEET_AUTH_KEY, or by a script's login(). This is the one place the CLI's own commands do not need an account but the thing they built does: service add, unit add, mount, and start all succeed on a signed-out host, the port is bound, and every request beneath a route is then refused until someone pairs the host.
What a refused client sees depends on what it asked for:
- A plain
GETorHEAD, which is what a browser sends when it navigates, gets a403whose body is a pairing page in place of what it asked for. The page shows a login code and the URL to claim it at, and reloads itself once the host is signed in, so the browser ends up where it was headed. - A
POSTor a WebSocket upgrade gets a bare403. There is no credential a client can present: the check is on the host, not on who is asking, so a signed-inyeeton the client machine makes no difference.
Two things stay open above the gate so a signed-out host can still be found and paired: the discovery manifest at /.well-known/yeet/manifest.json, and the pairing page's own files and event stream beneath /._/. yeet whoami tells you which side of the gate a host is on.
Build one, step by step
Every output on this page was captured from a real run; only ids, paths, and timestamps differ from what you will get. The service is built from three files in /home/you/pulse:
// pulse.js — long-lived: draws on the tty and logs a JSON line every second.
let n = 0;
setInterval(() => {
n++;
if (typeof tty !== "undefined") {
tty.clear();
tty.write(style.bold(`pulse ${n}`) + "\n");
}
console.log(JSON.stringify({ tick: n, at: Date.now() }));
}, 1000);
// snapshot.js — answers one request with one line, then exits.
// The work runs from a timer: a script that has finished before the
// request's console is attached has nothing left to stream.
setTimeout(() => {
console.log(JSON.stringify({ host: yeet.args.host, at: Date.now() }));
}, 100);
<!-- public/index.html -->
<!doctype html>
<title>pulse</title>
<h1>pulse</h1>
Create the service
$ yeet service new pulse -C .
Created service pulse
● noteworthy_shoulder (pulse) stopped restart=no (disabled)
The daemon minted the id noteworthy_shoulder and bound the alias pulse to it. The service is stopped, has the default restart policy no, and is disabled, so it will not start on boot. It has no units yet. -C (--working-directory) names the directory the service's scripts and static files live under, here /home/you/pulse, and the service runs its own copy of it; see where a service runs its scripts from. Without it, a hand-built service has nowhere to copy a local script from, and the first unit add -I says so:
$ yeet service unit add pulse/app -I ./pulse.js
│ Error: Daemon Error
│
│ Daemon returned error: /home/you/pulse/pulse.js is a local path, and the service has no working directory to copy it from
yeet service set pulse -C . gives it one afterwards, and a service built from a file takes the file's directory without being told. Without an alias you get the id alone, and -q prints just the id for scripts to capture:
$ yeet service new -q
marvelous_power
yeet service list shows every service, marking a disabled one at the end of its row:
$ yeet service list
ID ALIAS STATUS RESTART UNITS
noteworthy_shoulder pulse stopped no (disabled)
Add an isolate
$ yeet service unit add pulse/app --isolate ./pulse.js
Added unit pulse/app
app isolate
$ yeet service tree pulse
● noteworthy_shoulder (pulse) stopped restart=no (disabled)
└─app isolate
The unit is app, and it is an isolate unit because --isolate (-I) named a script. The path is canonicalized when you add it, so the unit remembers /home/you/pulse/pulse.js however you typed it, and a script that does not exist is refused on the spot. The script must sit under the working directory, and adding the unit takes a fresh copy of the working directory into the service; see where a service runs its scripts from. Like yeet run, the source can also be an http(s) URL or a git repo such as gh:yeet-src/heatsink, which is cloned when you add the unit and kept with the service by that name.
Add a web server
$ yeet service unit add pulse/web --web-server http://127.0.0.1:9100
Added unit pulse/web
web web-server http://127.0.0.1:9100
$ yeet service tree pulse
● noteworthy_shoulder (pulse) stopped restart=no (disabled)
├─app isolate
└─web web-server http://127.0.0.1:9100
--web-server (-W) makes a web-server unit, bound to that address once the service starts. The rules are those of yeet run -p: a literal IP address and a port, with an IPv6 address in brackets, as in http://[::1]:8080. A hostname is refused with Redirect targets require a literal IP address, and leaving the port off is refused because port 80 is privileged. http:// and ws:// both serve plain GET requests and WebSocket upgrades on the same port; the scheme states what the server is mostly for.
Mount routes
$ yeet service mount pulse/web -L /log -t app -p console
Mounted route /log on pulse/web
GET /log console@app
$ yeet service mount pulse/web -L /tty -t app
Mounted route /tty on pulse/web
GET /tty app
$ yeet service mount pulse/web -L / -S ./public
Mounted route / on pulse/web
GET / => /home/you/pulse/public
$ yeet service tree pulse
● noteworthy_shoulder (pulse) stopped restart=no (disabled)
├─app isolate
└─web web-server http://127.0.0.1:9100
├─GET / => /home/you/pulse/public
├─GET /log console@app
└─GET /tty app
Each route reads as METHOD /path and then what it serves. console@app is the console portal of unit app; a bare app is its tty, the default portal. => dir is a static directory. A route's target must be an isolate unit of the same service, and the web server must be a web-server unit: mount pulse/app … is refused with Unit 'app' is not a web server, and an unknown target with Route targets unknown unit. Mounting a path that is already mounted replaces the route.
Add a lazy unit for one-shot requests
$ yeet service unit add pulse/snapshot -I ./snapshot.js --lazy -- --host docs
Added unit pulse/snapshot
snapshot isolate --host docs (lazy)
$ yeet service mount pulse/web -L /snapshot -t snapshot -p console -T per-connection -d "one line per request"
Mounted route /snapshot on pulse/web
GET /snapshot console@snapshot per-connection one line per request
$ yeet service tree pulse
● noteworthy_shoulder (pulse) stopped restart=no (disabled)
├─app isolate
├─web web-server http://127.0.0.1:9100
│ ├─GET / => /home/you/pulse/public
│ ├─GET /log console@app
│ ├─GET /snapshot console@snapshot per-connection one line per request
│ └─GET /tty app
└─snapshot isolate --host docs (lazy)
Everything after -- is the unit's yeet.args, shown on its line, exactly as yeet run takes them. --lazy means the service does not start this unit; a route does, on first demand. -T per-connection gives every request its own fresh isolate, which runs snapshot.js once and streams its console until the script exits. -d is free text shown at the end of the route's line.
Start it
$ yeet service start pulse
✓ Starting pulse
$ yeet service tree pulse
● noteworthy_shoulder (pulse) running restart=no (disabled)
├─app isolate id=3705
├─web web-server http://127.0.0.1:9100
│ ├─GET / => /home/you/pulse/public
│ ├─GET /log console@app
│ ├─GET /snapshot console@snapshot per-connection one line per request
│ └─GET /tty app
└─snapshot isolate --host docs (lazy)
Three things changed: the service is running, the eager unit app got an isolate id, and the web server is listening. Had the port been taken, by anything, the start would have failed with Unit web failed to start: Failed to bind web server on 127.0.0.1:9100: Address already in use and nothing would be running. The lazy unit snapshot has no id because nothing has asked for it. The isolate is an ordinary one as far as yeet ps is concerned, named after the script as you gave it, and yeet attach -c 3705 tails its console the same as for a yeet run isolate:
$ yeet ps
ID STATUS UPTIME EVAL HEAP REDIRECTS NAME
3705 running 91ms 4ms 276.2 KiB - ./pulse.js
The NAME column is the script as the unit was given it: ./pulse.js for a unit added by hand, the absolute path for one that came from a file. A shared worker that the service's isolates opened is an isolate too, listed as worker:<service id>:<path>. start on a running service is not an error; it reports ✓ Starting and changes nothing.
Connect to it
The static route serves the page:
$ curl -s http://127.0.0.1:9100/
<!doctype html>
<title>pulse</title>
<h1>pulse</h1>
The console route over plain HTTP is a chunked text/plain stream of the running isolate's log, from the moment you connect:
$ curl -si --max-time 2.5 http://127.0.0.1:9100/log
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
x-content-type-options: nosniff
cache-control: no-store
x-accel-buffering: no
transfer-encoding: chunked
date: Mon, 28 Sep 2026 22:12:34 GMT
{"tick":1,"at":1790633555654}
{"tick":2,"at":1790633556654}
The same route over WebSocket is one text frame per message:
$ timeout 2.5 websocat -t -n ws://127.0.0.1:9100/log </dev/null
{"tick":7,"at":1790633561655}
{"tick":8,"at":1790633562654}
The per-connection route runs snapshot.js afresh for each request and ends the response when the script exits. Two requests, two isolates, two different timestamps:
$ curl -s http://127.0.0.1:9100/snapshot
{"host":"docs","at":1790633557447}
$ curl -s http://127.0.0.1:9100/snapshot
{"host":"docs","at":1790633557601}
The tty route is the screen, and it is WebSocket only. A plain GET is told to upgrade; a WebSocket client receives the PTY's bytes and can send keystrokes and a resize frame back, as described under what a client receives:
$ curl -si http://127.0.0.1:9100/tty
HTTP/1.1 426 Upgrade Required
upgrade: websocket
connection: Upgrade
content-length: 0
$ timeout 3 websocat --binary -n ws://127.0.0.1:9100/tty </dev/null | cat -v
^[[2J^[[H^[[1mpulse 4^[[0m^M
{"tick":4,"at":1790633558654}^M
^[[2J^[[H^[[1mpulse 5^[[0m^M
{"tick":5,"at":1790633559654}^M
A path that is not mounted is a 404, and a mounted path with the wrong method is a 405 that names the allowed one:
$ curl -si http://127.0.0.1:9100/nothing
HTTP/1.1 404 Not Found
$ curl -si -X POST http://127.0.0.1:9100/log
HTTP/1.1 405 Method Not Allowed
allow: GET
Read it back
get prints a service's settable properties, and unit get a unit's, in sections like systemctl status. The flags are the ones set takes, without values: one flag prints its value bare, for scripts, and several print key value rows.
$ yeet service get pulse
Service noteworthy_shoulder (pulse) running
Attributes
alias pulse
restart no
enabled false
$ yeet service get pulse -R
no
$ yeet service get pulse -a -e
alias pulse
enabled false
$ yeet service unit get pulse/snapshot
Service noteworthy_shoulder (pulse) running
Unit snapshot isolate (lazy)
Attributes
isolate /home/you/pulse/snapshot.js
lazy true
args --host docs
$ yeet service unit get pulse/web
Service noteworthy_shoulder (pulse) running
Unit web web-server http://127.0.0.1:9100
Attributes
web-server http://127.0.0.1:9100/
Routes
GET / => /home/you/pulse/public
GET /log console@app
GET /snapshot console@snapshot per-connection one line per request
GET /tty app
tree takes the same scoping. yeet service tree pulse web prints the web server and its routes alone, and yeet service tree pulse snapshot the one unit line.
Change it
Routes and service properties change while the service runs. A mount takes effect immediately:
$ yeet service mount pulse/web -L /each -t app -p console -T per-connection -c 4
Mounted route /each on pulse/web
GET /each console@app per-connection max=4
$ curl -s --max-time 1.5 http://127.0.0.1:9100/each
{"tick":1,"at":1790633568038}
$ yeet service set pulse -R always
Updated service pulse
● noteworthy_shoulder (pulse) running restart=always (disabled)
Units do not, and neither do dev mode and the working directory, which move where every unit runs from. Adding, removing, or changing a unit, and set --dev, --no-dev, or -C, need the service down:
$ yeet service unit set pulse/snapshot -H 512MiB
│ Error: Daemon Error
│
│ Daemon returned error: Service must be down to change its units
$ yeet service stop pulse
✓ Stopping pulse
$ yeet service unit set pulse/snapshot -H 512MiB
Updated unit pulse/snapshot
snapshot isolate --host docs (lazy)
stop takes every isolate of the service down and closes its web servers, so a client gets a refused connection rather than an HTTP error until the next start. The tree keeps the units, minus their ids:
$ yeet service tree pulse
● noteworthy_shoulder (pulse) stopped restart=always (disabled)
├─app isolate
├─web web-server http://127.0.0.1:9100
│ ├─GET / => /home/you/pulse/public
│ ├─GET /log console@app
│ ├─GET /snapshot console@snapshot per-connection one line per request
│ └─GET /tty app
└─snapshot isolate --host docs (lazy)
Enable it
enable marks the service to start on boot, and drops the (disabled) from its line. It prints nothing. --now also starts it, so enable --now is the usual one command for "run this from now on":
$ yeet service enable pulse --now
✓ Starting pulse
$ yeet service tree pulse
● noteworthy_shoulder (pulse) running restart=no
├─app isolate id=3716
…
Once enabled, the service appears in a plain yeet service list. disable is the reverse, and disable --now also stops it. set -e and set -D change the same flag without starting or stopping anything.
Write it down
export prints the whole service as a file, TOML by default:
$ yeet service export pulse
alias = "pulse"
restart = "no"
enabled = false
working-directory = "/home/you/pulse"
[units.app]
isolate = "/home/you/pulse/pulse.js"
[units.web]
web-server = "http://127.0.0.1:9100/"
[units.web.routes."/"]
static = "/home/you/pulse/public"
[units.web.routes."/log"]
target = "app"
portal = "console"
[units.web.routes."/snapshot"]
target = "snapshot"
portal = "console"
tenancy = "per-connection"
description = "one line per request"
[units.web.routes."/tty"]
target = "app"
[units.snapshot]
isolate = "/home/you/pulse/snapshot.js"
lazy = true
args = ["--host", "docs"]
-o pulse.toml writes it to a file instead, -j writes JSON, and an output name ending in .json does too. Defaults are left out: a unit that is eager, on the default heap, with no args, is just its isolate line. The paths are the project's, never the service's copy of it, and dev mode is left out too: it is how one machine runs the service, not part of what the service is.
Tear it down and bring it back
A running service refuses to be removed unless you say -f, which stops it first:
$ yeet service remove pulse
Invalid state: `pulse` is running. Stop it first, or pass -f
$ yeet service remove pulse -f
Removed service pulse
import rebuilds a service from the file export wrote, in one request, so it either exists whole or not at all. --now enables and starts it in the same breath:
$ yeet service import pulse.toml --now
Created service pulse
✓ Starting large_period
● large_period (pulse) running restart=no
├─app isolate id=3719
├─web web-server http://127.0.0.1:9100
│ ├─GET / => /home/you/pulse/public
│ ├─GET /log console@app
│ ├─GET /snapshot console@snapshot per-connection one line per request
│ └─GET /tty app
└─snapshot isolate --host docs (lazy)
The id is new; only the alias carries over. Importing a file whose alias is taken is refused with Service name 'pulse' is taken, and -a imports it under another alias instead, which is how you run two copies of one description.
Tenancy: what a connection gets
The -T of mount decides how connections to a route map onto the target unit. Three clients on three routes of the same app unit:
| Tenancy | What a connection gets | A second connection at the same time |
|---|---|---|
shared (default) | The unit's one running isolate. Every client sees the same stream from the moment it connects. | Also served, seeing the same thing. |
per-connection | A fresh isolate of the unit's script, started for this connection and gone when it ends. Its output starts from the script's beginning. | Its own fresh isolate. |
exclusive | The unit's one running isolate, for this client alone. | Refused with 409 Conflict until the first leaves. |
Two shared readers see the same ticks:
$ curl -s --max-time 2.2 http://127.0.0.1:9100/log &
$ curl -s --max-time 2.2 http://127.0.0.1:9100/log
{"tick":6,"at":1790633650472}
{"tick":7,"at":1790633651472}
{"tick":6,"at":1790633650472}
{"tick":7,"at":1790633651472}
Two per-connection readers each get a script that just started:
$ curl -s --max-time 1.5 http://127.0.0.1:9100/each &
$ curl -s --max-time 1.5 http://127.0.0.1:9100/each
{"tick":1,"at":1790633653212}
{"tick":1,"at":1790633653212}
The second exclusive reader is turned away:
$ curl -si --max-time 2.5 http://127.0.0.1:9100/one &
$ curl -si http://127.0.0.1:9100/one
HTTP/1.1 409 Conflict
-c caps concurrent connections on any route; a connection past the cap gets the same 409 Conflict. The tree shows live per-connection isolates beneath their route and counts them on the unit:
$ yeet service tree pulse
● snappy_sandwich (pulse) running restart=no (disabled)
├─app isolate id=3738 (2 connections)
├─web web-server http://127.0.0.1:9100
│ ├─GET /each console@app per-connection max=4 (2 connections)
│ │ ├─app isolate id=3739
│ │ └─app isolate id=3740
…
Those replicas are in yeet ps too, under the unit's script name, and leave when their client does.
The same holds for a tty route. per-connection on a tty gives every WebSocket its own isolate, each drawing its own screen from the script's first frame, which is how one service serves a TUI or a yeetkit page to any number of browsers at once:
$ yeet service mount cov/web -L /tty -t app -T per-connection
$ yeet service tree cov
● guilty_grocery (cov) running restart=no (disabled)
├─app isolate id=4044 (2 connections) (lazy)
└─web web-server http://127.0.0.1:9180
├─GET /log console@app
└─GET /tty app per-connection (2 connections)
├─app isolate id=4045
└─app isolate id=4046
One-shot scripts
A per-connection route on a script that logs and exits is a complete request handler: the response body is the console, and it ends when the script does. This is how the Prometheus exporter pattern works. The script has to still be running when the request's console is attached, which happens right after the isolate launches. A script whose whole body runs synchronously, console.log("hi") and nothing else, has exited by then, the daemon logs died during its launch, and the client gets 502 Bad Gateway. Doing the work from a timer or a promise callback is enough, as snapshot.js above does, and as main().then(console.log) does.
Lazy units
An isolate unit is eager by default: start launches it with the service and it runs until the service stops. --lazy (-Z) hands that to the routes:
- A
sharedorexclusiveroute on a lazy unit launches the unit's isolate on the first connection, and the isolate then stays up. It is not stopped when the last client leaves; the tree shows its id from then on. - A
per-connectionroute never needs the unit's own isolate at all. Each connection gets its own, so a lazy unit behind only per-connection routes is a template that never runs on its own. This is the right setting for a one-shot script: eager would run it once at start, for nothing, and its exit would count against the restart policy.
--eager (-E) is the default and exists so unit set -E can undo --lazy. The two flags are refused together.
Restart policy
-R on new, import, and set picks what the daemon does when a unit goes down. The choices are no, on-failure, on-failure:<max>, and always; anything else is refused by the parser. A crash.js that logs, then throws from a timer half a second later, shows each:
// crash.js
console.log("starting");
setTimeout(() => { throw new Error("boom"); }, 500);
| Policy | When a unit that came up later exits | When a unit dies while launching |
|---|---|---|
no (default) | Nothing is restarted. The service is exited, or failed with the error when the unit crashed. | start fails and the service stays stopped. |
on-failure:<max> | A crash is restarted up to max times, then the service is failed. A clean exit is not restarted. | The start is retried max times, then fails and the service is failed. |
on-failure | A crash is restarted for as long as the unit keeps crashing. A clean exit is not restarted. | The start is retried until the unit comes up; the service is restarting meanwhile. |
always | The unit is restarted after any exit, forever. | As on-failure without a max. |
"Dies while launching" means the module could not be compiled or evaluated: a syntax error, or a throw at the top level of the script. A web server whose port is taken counts too. An uncaught error in a callback later on is a crash, not a launch failure: the unit had come up, so the left column applies. Between restarts the service shows restarting, and the delay grows while a unit keeps dying quickly, starting over once it has stayed up for ten seconds.
Under no, the crash ends the service:
$ yeet service start flaky
✓ Starting flaky
$ yeet service tree flaky
● negligible_raw (flaky) failed restart=no (disabled): Error: boom
└─crash isolate
Under always, the daemon keeps respawning it, and the tree shows the attempt count:
$ yeet service set flaky -R always
$ yeet service start flaky
✓ Starting flaky
$ yeet service tree flaky
● negligible_raw (flaky) running restart=always (disabled)
└─crash isolate id=3725
$ yeet service tree flaky
● negligible_raw (flaky) restarting restart=always (disabled) (attempt 1 of ∞): crash: Error: boom
└─crash isolate
stop ends the loop.
A unit that cannot launch at all keeps start waiting: the start takes the service back down, tries again under the policy, and returns once every unit is up or the policy gives up, showing the attempt it is on meanwhile. Under on-failure without a max, or always, the retries never give up, so Ctrl-C is the way out: it backs out of the start and stops the service. restart behaves the same once it has taken the service down. Under on-failure:2 the start gives up after two retries, fails the command, and the tree records the retries and the reason:
$ yeet service start flaky
✗ Starting flaky
Daemon returned error: Unit `broken` failed to start: Died during launch: Module evaluate failed: Error: boom at top
at /home/you/pulse/broken.js:2:7
$ yeet service tree flaky
● negligible_raw (flaky) failed restart=on-failure:2 (disabled) after 2 retries: broken: Died during launch: Module evaluate failed: Error: boom at top at /home/you/pulse/broken.js:2:7
├─crash isolate
└─broken isolate
The service statuses you will see, on the tree's root line and in list, are stopped, running, exited (a unit ended cleanly and the policy let it), restarting (between attempts), and failed (a crash the policy let end, or retries that gave up).
A shared route on a lazy unit whose script exits counts the same way: under always the exit is a failure and the service restarts, so a one-shot script belongs behind per-connection.
Where a service runs its scripts from
Every service has a working directory: the directory its local scripts, everything they import, and its static routes live under. A service built from a file takes the file's own directory, or the directory the file's working-directory names, relative to the file. A hand-built one takes -C on new, and set -C changes it.
Unless it is a dev service, the service does not run your checkout. It copies the working directory into its own directory on the daemon and every launch runs that copy, so an edit, a git pull, or a deleted checkout leaves the running service alone. Here pulse.js is edited to add a field, and the restarted service still logs the line it logged before:
$ curl -s --max-time 1.2 http://127.0.0.1:9100/log | head -1
{"tick":2,"at":1790811522683}
$ sed -i 's/tick: n, at/tick: n, edited: true, at/' pulse.js
$ yeet service restart pulse
✓ Restarting pulse
$ curl -s --max-time 1.2 http://127.0.0.1:9100/log | head -1
{"tick":2,"at":1790811525436}
The copy is taken when the service's definition changes: when it is created or imported, when a unit is added or a static route mounted, when -C names another directory, and when a dev service is switched back with --no-dev. start, restart, and a crash restart under the restart policy never take one. To ship an edit, build the service again from its file:
$ yeet service export pulse -o pulse.toml
$ yeet service remove pulse -f
$ yeet service import pulse.toml --now
A unit from a git repo or an http(s) URL is fetched once, when it is added, and kept with the service the same way; a restart never fetches it again.
Since the copy is all the service has, every local script and static directory must sit under the working directory, and one that does not is refused when you add it. A dev service copies nothing, so it takes paths from anywhere, and a file imported with --dev needs no working-directory; switching it to a copy with --no-dev later refuses any path outside the working directory the same way:
$ yeet service unit add pulse/other -I ../elsewhere/pulse.js
│ Error: Daemon Error
│
│ Daemon returned error: /home/you/elsewhere/pulse.js is outside the service's working directory /home/you/pulse: set one that holds it, or make it a dev service
A service file whose scripts or static routes reach above its own directory, such as a service.toml in node/ serving ../web, needs working-directory = ".." to take them in. The copy holds files, directories, and symlinks as symlinks; a symlink pointing outside the working directory still points there, so its target must stay put. FIFOs and sockets are left out. tree, get, and export always show the project's paths, not the copy's.
The dev loop
A dev service runs its working directory in place: nothing is copied, and every isolate reads its script and its imports from disk as it starts. It is for the time you, or an agent, spend changing the service: edit a file, and the next isolate that starts runs the edit, with no export, remove, and import in between.
It does not watch the files. An edit shows up when an isolate launches:
- on
restart, orstopandstart, for the service's eager units; - on the next connection to a per-connection route, since every connection starts a fresh isolate; reloading the page is enough;
- when the restart policy brings a crashed unit back.
A unit that is already running keeps the code it started with until one of those happens. Static routes read their files on every request, so a page is always current. yeet run --watch is still the tool for one script you want re-run on every save.
--dev is a flag, on new, import, set, and start. start --dev is the quickest way in:
$ yeet service stop pulse
✓ Stopping pulse
$ yeet service start pulse --dev
✓ Starting pulse
$ curl -s --max-time 1.2 http://127.0.0.1:9100/log | head -1
{"tick":2,"edited":true,"at":1790811528225}
$ yeet service tree pulse
● noteworthy_shoulder (pulse) running restart=no (disabled) (dev)
├─app isolate id=2
├─web web-server http://127.0.0.1:9100
│ ├─GET / => /home/you/pulse/public
│ ├─GET /log console@app
│ └─GET /snapshot console@snapshot per-connection one line per request
└─snapshot isolate --host docs (lazy)
The edit the copy had held back now runs. A per-connection route needs no restart at all: edit snapshot.js, and the next request runs it.
$ sed -i 's/host: yeet.args.host, at/host: yeet.args.host, v: 2, at/' snapshot.js
$ curl -s --max-time 2 http://127.0.0.1:9100/snapshot
{"host":"docs","v":2,"at":1790811529079}
So the loop, for a service described by a file, is:
yeet service import pulse.toml --dev --now # once: build it and run the checkout
# edit the scripts, their imports, the static files
curl http://127.0.0.1:9100/snapshot # a per-connection route runs the edit on the next request
yeet service restart pulse # eager units run it once restarted
When it is right, start --no-dev (or set --no-dev on a stopped service) takes a copy of the checkout as it is at that moment and runs that from then on; later edits no longer show until you build it again. Switching either way needs the service down, as changing a unit does:
$ yeet service set pulse --dev
│ Error: Daemon Error
│
│ Daemon returned error: Service must be down to change its units
Dev mode is never part of the service file. export leaves it out, and a file that says dev = true is refused, so the file you commit is the file you deploy: import --dev runs it from your checkout while you work, and a plain import runs a copy on a server. get -d reads it, and (dev) on the tree's root line shows it.
Ephemeral services
An ephemeral service is one you mean to throw away: stopping it removes it, as remove -f would, so a quick try or a service an agent stands up for one task leaves nothing behind. --ephemeral (-E) on new makes one. Here a directory holds a one-unit service file and its script:
# service.toml
alias = "scratch"
[units.ticker]
isolate = "./ticker.js"
// ticker.js
setInterval(() => console.log("tick"), 1000);
$ yeet service new -f service.toml --ephemeral
Created service scratch
● caring_spite (scratch) stopped restart=no (disabled) (ephemeral)
└─ticker isolate
$ yeet service start scratch
✓ Starting scratch
$ yeet service list
ID ALIAS STATUS RESTART UNITS
caring_spite scratch running no ticker (disabled)
$ yeet service stop scratch
✓ Stopping scratch
Stopped and removed service scratch
$ yeet service list
ID ALIAS STATUS RESTART UNITS
disable --now removes it the same way, and so does Ctrl-C backing out of a start that is still waiting on its units. Nothing else does:
restartstops and starts it, and keeps it.- A service that exits or fails on its own stays listed as
exitedorfailed, so you can see why, until you remove it. - When the daemon starts, it removes every ephemeral service it finds, whatever state it was left in, and logs
Removed ephemeral service scratch.An ephemeral service never outlives the daemon that ran it.
set --no-ephemeral changes your mind partway through, and the service then stops like any other. Unlike dev mode, -E and --no-ephemeral take effect on a running service:
$ yeet service new -f service.toml -E
Created service scratch
● triangular_intention (scratch) stopped restart=no (disabled) (ephemeral)
└─ticker isolate
$ yeet service start scratch
✓ Starting scratch
$ yeet service restart scratch
✓ Restarting scratch
$ yeet service tree scratch
● triangular_intention (scratch) running restart=no (disabled) (ephemeral)
└─ticker isolate id=2
$ yeet service set scratch --no-ephemeral
Updated service scratch
● triangular_intention (scratch) running restart=no (disabled)
$ yeet service stop scratch
✓ Stopping scratch
$ yeet service list
ID ALIAS STATUS RESTART UNITS
triangular_intention scratch stopped no ticker (disabled)
Like dev mode, it is how this machine runs the service, not part of the service file: export leaves it out and a file that says ephemeral = true is refused. get -E reads it, and (ephemeral) on the tree's root line shows it.
The service file
export writes it and import and new -f read it. TOML or JSON, told apart by the text itself or forced with -j:
alias = "pulse" # optional; the id is always minted
restart = "always" # no | on-failure | on-failure:<max> | always
enabled = true # start on boot
working-directory = ".." # optional, relative to the file; default the file's directory
[units.app] # an isolate unit named app
isolate = "./pulse.js" # path, http(s) URL, or git repo, as unit add -I takes it
lazy = false # optional, default false
heap-limit = "512MiB" # optional, default 384MiB
args = ["--host", "docs"] # optional, yeet.args
[units.web] # a web-server unit named web
web-server = "http://127.0.0.1:9100"
[units.web.routes."/log"] # a route, keyed by its path
target = "app" # isolate unit in the same service
portal = "console" # tty (default) | console
tenancy = "per-connection" # shared (default) | per-connection | exclusive
method = "GET" # optional, default GET; what mount -X sets
cap = 8 # optional, no cap by default
description = "the log" # optional, free text
[units.web.routes."/"]
static = "./public" # a directory, instead of target
[units.web.routes."/legacy"]
upstream = "ws://10.0.0.5:9000" # a ws:// or wss:// URL, instead of target
A relative isolate, static, or working-directory path resolves against the file's directory, and against the directory you ran the command in when the file comes from stdin. Every local isolate and static path must sit under the working directory. export writes absolute paths, so an exported file imports from anywhere on the same host; write relative ones by hand for a file that lives next to its scripts. There is no dev or ephemeral key: dev mode and ephemeral are flags on the command, never part of the file. The whole service lands in one request, so a file with a script that does not exist creates nothing:
$ yeet service import sub/rel.toml # isolate = "./pulse.js", and sub/pulse.js does not exist
│ Error: Command Failed
│
│ Failed to canonicalize local script sub/./pulse.js: No such file or directory (os error 2)
$ cat sub/rel.toml | yeet service import - # resolves against the directory you ran it in instead
Created service rel
JSON, and a service written by a script
The file can be JSON with the same keys, and import tells the two apart by looking at the text: a JSON file needs no flag, whatever its name. -j forces the JSON reader, for a case the text does not settle, and it refuses TOML with is not a service file. export -j writes the JSON form, so the two round-trip:
$ yeet service export pulse -j | yeet service import - -a pulse-2
Since a description is just JSON on stdin, a script can write one. yeet run in a pipe prints console.log lines to stdout and nothing else, so a script that logs a description, shaped by its yeet.args, is a service generator:
// conf.js — prints a service description, shaped by yeet.args.
const env = yeet.args.env ?? "dev";
const port = env === "prod" ? 9100 : 9190;
console.log(JSON.stringify({
alias: `pulse-${env}`,
restart: env === "prod" ? "always" : "no",
units: {
app: { isolate: "./pulse.js", args: ["--env", env] },
web: { "web-server": `http://127.0.0.1:${port}`, routes: { "/log": { target: "app", portal: "console" } } },
},
}, null, 2));
$ yeet run conf.js --env prod | yeet service import -
Created service pulse-prod
● grouchy_chart (pulse-prod) stopped restart=always (disabled)
├─app isolate --env prod
└─web web-server http://127.0.0.1:9100
└─GET /log console@app
$ yeet run conf.js --env dev | yeet service import - -j
Created service pulse-dev
● nautical_remove (pulse-dev) stopped restart=no (disabled)
├─app isolate --env dev
└─web web-server http://127.0.0.1:9190
└─GET /log console@app
The relative ./pulse.js resolves against the directory you ran the pipeline in, as any stdin import does. The same works for new -f -, and --now on the import starts what the script described.
Command reference
Every command below takes a service by id or alias, and a unit as SERVICE/NAME. Where a command takes [SERVICE/NAME] as a positional, -u (or -W for mount and unmount) gives the same thing as a flag instead.
yeet service list
Lists services as rows: id, alias (- when none), status, restart policy, unit names. It shows every service, whatever its state, and marks a disabled one (disabled) at the end of its row. ls is an alias for list.
yeet service tree [SERVICE] [UNIT]
Draws every service with its units and routes. With SERVICE, one service. With SERVICE UNIT, one unit: an isolate's line, or a web server with its routes. Running isolates show id=, per-connection replicas are drawn beneath their route, and the root line carries the status, the restart policy, (disabled), (dev), (ephemeral), and, when the service is restarting or failed, the attempt count and the reason.
yeet service new [ALIAS]
Creates an empty, stopped, disabled service. Takes -R for the restart policy, -C / --working-directory <DIR> for its working directory, -d / --dev to make it a dev service, and -E / --ephemeral to make it ephemeral, or a whole description with -f <FILE> (- for stdin) plus -j to force JSON, in which case it is import. -q prints only the minted id and suppresses progress banners. -y auto-accepts consent prompts and -% caps archive inflation while the file's scripts are fetched, as yeet run takes them. An alias that another service holds is refused with Service name '…' is taken.
yeet service import <FILE>
Creates a service from a file, as new -f does. -a <ALIAS> overrides the file's alias, -R its restart policy, and -C / --working-directory <DIR> its working-directory. -d / --dev imports it as a dev service. -n / --now enables the service and brings it up once it exists. -j, -q, -y, and -% as for new.
yeet service export <SERVICE>
Prints the service as TOML. -o <FILE> writes a file instead (-o - prints), -j writes JSON, and so does an -o name ending in .json.
yeet service remove <SERVICE>
Removes a stopped service and everything in it. A running one is refused unless -f / --force, which stops it first.
yeet service enable <SERVICE> / disable
Sets whether the service starts on boot. -n / --now also starts it (for enable) or stops it (for disable). Without --now, the running state does not change. enable alone prints nothing.
yeet service start / stop / restart <SERVICE>
start launches every eager isolate unit and binds every web server, and reports ✓ Starting, or ✗ Starting with the daemon's reason when a unit cannot launch. On a running service it is a no-op. stop takes every isolate of the service down, lazy ones and per-connection replicas included, and closes the web servers; on an ephemeral service it then removes it. restart is a stop and a start, and is how a unit change takes effect. It runs the same copy of the working directory; a dev service reads the checkout again instead. start -d / --dev and start --no-dev switch dev mode first, which needs the service down, as set does.
yeet service get <SERVICE> / set
set changes a service's properties: -R <POLICY>, -a <ALIAS> to name or rename it, -A / --no-alias to drop the alias so only the id answers, -e to enable, -D to disable, -d / --dev and --no-dev to switch dev mode, -E / --ephemeral and --no-ephemeral to switch whether stopping it removes it, and -C / --working-directory <DIR> to move the working directory. Properties left out are left alone. The alias, restart policy, enablement, and ephemeral change on a running service; dev mode and the working directory need it down. get reads the same properties, with -d for dev mode, -E for ephemeral, and -C for the working directory: no flags prints them in sections, one flag prints its value bare, several print rows.
yeet service unit add <SERVICE/NAME> <-I SOURCE | -W URL> [-- ARGS...]
Adds a unit. Exactly one of the two kinds:
-I/--isolate <SOURCE>runs a script. A local one must sit under the service's working directory, unless the service is a dev service.-Z/--lazyor-E/--eagerpicks when it starts;-H/--heap-limit <SIZE>sets its old-generation heap budget (512MiB,1GiB,2GB; default 384MiB); everything after--is itsyeet.args.-qsuppresses the clone banner for a git source,-yauto-accepts consent prompts,-%caps archive inflation.-W/--web-server <URL>binds anhttp://orws://address.
A name already used in the service is refused with Unit '…' is taken. The service must be down.
yeet service unit set <SERVICE/NAME> [-- ARGS...]
Changes a unit. For an isolate: -Z / -E, -H <SIZE> or -N / --no-heap-limit to return to the default, -- ARGS to replace the arguments or -A / --no-args to clear them. For a web server: -W <URL> moves it. A property left out is left alone; isolate flags on a web server (Unit 'web' is not an isolate) and -W on an isolate (Unit '…' is not a web server) are refused, as is a call with no flags (Nothing to set). The service must be down.
yeet service unit get <SERVICE/NAME>
Reads a unit's properties, as get does for a service. The flags are -I (the script), -Z (lazy), -H (heap), -A (args), and -W (the web server's URL). With no flags, a web server's routes are printed last.
yeet service unit remove <SERVICE/NAME>
Removes the unit. The service must be down. An isolate that a route still targets is refused with Unit '…' is the target of route …: unmount it first; a web server goes together with its routes.
yeet service mount <SERVICE/UNIT> -L <ROUTE> …
Mounts a route on a web server, and can be done while the service runs. -L / --location is the path, given a leading slash if it lacks one. Then one of three things to serve:
-t/--target <UNIT>: an isolate unit of the same service.-p/--portalpickstty(default) orconsole;-T/--tenancypicksshared(default),per-connection, orexclusive.-S/--static <DIR>: the files beneath a directory, over plain HTTP. The directory must exist, and sit under the working directory unless the service is a dev service.-U/--upstream <URL>: forward the path to aws://orwss://URL.http://is refused.
And for any of them: -X / --request <METHOD>, the HTTP method the route answers, GET by default and spelled in upper case; a request with another method gets a 405 naming the allowed one. -c / --cap <N>, a ceiling on concurrent connections; -d / --description <TEXT>, shown at the end of the route's line. A path already mounted is replaced.
yeet service unmount <SERVICE/UNIT> -L <ROUTE>
Removes the route at that path. A path that is not mounted is not an error.
Refused up front
| What you tried | What you get |
|---|---|
An alias another service holds, on new, import, or set -a | Service name '…' is taken |
| A unit name already in the service | Unit '…' is taken |
unit add, unit set, or unit remove on a running service | Service must be down to change its units |
remove on a running service | Invalid state: … is running. Stop it first, or pass -f |
A script path that does not exist, in unit add -I or a file | Failed to canonicalize local script …: No such file or directory |
| A local script or static directory outside the working directory | … is outside the service's working directory …: set one that holds it, or make it a dev service |
| A local script on a hand-built service with no working directory | … is a local path, and the service has no working directory to copy it from |
set --dev, --no-dev, or -C, or start --dev, on a running service | Service must be down to change its units |
A -C or working-directory path that does not exist | The working directory … cannot be used: No such file or directory |
A dev key in a service file | unknown field `dev` |
An ephemeral key in a service file | unknown field `ephemeral` |
-E together with --no-ephemeral, on set | the argument '--ephemeral' cannot be used with '--no-ephemeral' |
| A web-server URL with a hostname | Redirect targets require a literal IP address, got localhost |
| A web-server URL without a port | Binding http://127.0.0.1/ requires a privileged port |
-I together with -W, on unit add | '--isolate <SOURCE>' cannot be used with '--web-server <URL>' |
-Z together with -E | '--lazy' cannot be used with '--eager' |
mount on an isolate unit | Unit '…' is not a web server |
mount -t naming a unit that does not exist | Route targets unknown unit '…' |
mount -t naming a web-server unit | Route targets unit '…', which is not an isolate |
start when a web server's port is taken | Unit web failed to start: Failed to bind web server on …: Address already in use |
unit remove on an isolate a route targets | Unit '…' is the target of route …: unmount it first |
mount -t together with -S | '--target <TARGET>' cannot be used with '--static <DIR>' |
mount -S on something that is not a directory | A static route serves a directory, and … is not one |
mount -U with an http:// URL | An upstream is a ws:// or wss:// URL, not http:// |
mount with none of -t, -S, -U | the following required arguments were not provided: --target |
A -T, -p, or -R value outside the list | invalid value '…' from the parser |
A lower-case method, -X get | A method is spelled as on the wire: GET |
| Any command naming a service that does not exist | No service … |