Run LatchAI as a service
Triggers only fire while the engine is running. There are three ways to keep one up, and they suit different days:
npm run enginein a terminal — fine while you are working, gone when you close the window.- The packaged desktop app — closing the window leaves it in the menu bar with the engine up.
- A launchd agent — no window at all, starts at login, restarts on crash. This is what an automation that fires at 3am actually needs.
Install the launchd agent
Section titled “Install the launchd agent”The engine CLI writes and loads the LaunchAgent for you:
npx tsx packages/engine/src/cli.ts daemon installnpx tsx packages/engine/src/cli.ts daemon statusnpx tsx packages/engine/src/cli.ts daemon uninstallinstall writes the plist, boots out any previous job, waits for it to actually disappear,
bootstraps the new one and kickstarts it. uninstall boots the job out and deletes the plist.
The engine’s entry point is resolved from the code checkout the command ran from, and the data
location from the LatchAI home it resolved — the two are independent, as they are everywhere else. Run
daemon install from a checkout that has never had npm install, and it refuses with
tsx not found at … — run npm install in <codeRoot> first rather than writing a plist aimed at
nothing.
What the plist contains
Section titled “What the plist contains”~/Library/LaunchAgents/com.latchai.engine.plist, in five load-bearing keys:
| Key | Value |
|---|---|
Label |
com.latchai.engine |
ProgramArguments |
your node binary, the tsx CLI, packages/engine/src/cli.ts, serve (from a packaged app: the bundled engine-dist/cli.mjs, which runs itself) |
WorkingDirectory |
the LatchAI home |
RunAtLoad / KeepAlive |
both true — start at login, restart on crash |
StandardOutPath / StandardErrorPath |
<latchHome>/runs/daemon.log |
It carries a LATCHAI_HOME environment variable only when the home it was installed for isn’t the
one the engine would find on its own — so a standard layout resolves the home the usual way (the
~/.config/latchai/home pointer, then ~/LatchAI), and relocating the home later keeps working.
That is also the only environment variable in the file. A launchd job inherits nothing from your shell,
so LATCHAI_PORT or LATCHAI_BIND exported in .zshrc do not reach the daemon: it serves
127.0.0.1:7777 unless you edit the plist yourself.
Checking on it
Section titled “Checking on it”npx tsx packages/engine/src/cli.ts daemon status# [latchai] com.latchai.engine: state=running pid=41207
curl -s localhost:7777/api/status | head -c 200 # the engine actually answeringtail -f ~/LatchAI/runs/daemon.log # everything it printsdaemon.log is where the engine’s console goes once nothing is attached to a terminal — the boot line
with the resolved provider, MCP connection statuses, secret-resolution failures, and any crash stack
that made KeepAlive restart it.
Two launchd quirks, already handled
Section titled “Two launchd quirks, already handled”Bootstrap can leave the job pended. Loading a job does not reliably start it, so the installer
issues an explicit kickstart rather than assuming. (It also waits for a previous bootout to finish
before bootstrapping — that teardown is asynchronous, and a bootstrap issued too soon fails with an
unhelpful I/O error.)
launchd’s PATH is minimal. That breaks spawning npx-based
MCP servers, which is exactly the sort of failure that only shows up overnight.
LatchAI prepends node’s own bin directory and Homebrew (/opt/homebrew/bin, /usr/local/bin) when
spawning stdio MCP servers, so a launchd-started daemon has the same tool surface a terminal-started
one does.
What reloads, and what doesn’t
Section titled “What reloads, and what doesn’t”The engine loads its own code into memory at startup and never re-reads it. Everything else varies, and knowing which is which saves a restart — or explains a change that isn’t taking:
| Change | Takes effect |
|---|---|
packages/engine source |
Restart required. A daemon left running across an engine edit silently executes stale logic. |
latchai.config.json |
Live. The home directory is watched; a provider edit reloads on the next inference and the workbench sees a config.reloaded event. |
agents/, workflows/ |
Live. Definitions are re-read per run, and triggers are re-armed when the workflows directory changes. |
mounts.json, hand-edited |
Restart required — it is read once when the engine boots. Adding or removing a mount through the app goes through the running engine and needs no restart. |
The restart recipe, under launchd:
launchctl kickstart -k gui/$(id -u)/com.latchai.engine-k kills the running instance first, so this is the “I just upgraded the engine” button. Without
launchd, stop the terminal process and start it again — same effect, more typing.
Surviving the night
Section titled “Surviving the night”KeepAlive restarts a daemon that died, but the engine’s first job is not to die. Two nets sit under
it:
- An uncaught exception no longer takes the process down. One malformed request — a bad
percent-escape in a URL is enough — used to exit the engine, with every trigger, MCP child and open
socket. The
servecommand installs process-level handlers that log the full stack and keep running. They are installed only onserve: the one-shot CLI commands are scripts, and a script that dies on an error is behaving correctly. - A stopping engine drains its run log first, so a run’s
runs/<runId>.jsonlis complete rather than stopping several events short of what the run actually did.
The packaged desktop app instead
Section titled “The packaged desktop app instead”The packaged desktop app is the other way to keep the engine alive, and it needs no launchd at all. It requires macOS 13 (Ventura) or newer.
Closing the window does not quit it — the app deliberately stays alive with no window so triggers keep firing, and the menu-bar tray is how you get back to it:
- Open LatchAI — a window, or focus the one you have.
- Restart Engine — stop whatever owns
:7777(an external daemon included), start a fresh engine, reload the window. - Check for Updates…
- Quit.
A second launch — from Finder, open -a, or an installer’s relaunch — focuses the running copy instead
of starting a rival: two instances would fight over the tray, the engine and the updater. On quit, and
before an update installs, the engine child is stopped for real (SIGTERM, ~1.5s of grace, then SIGKILL)
and the quit is held open until it is gone. If the engine keeps exiting, the app respawns it — capped at
five exits a minute, after which it stops and says so rather than fork-bombing.
Packaged builds also update themselves. The app checks at launch and every six hours, downloads and
stages in the background, and offers a restart. With no window open it does not prompt at all: the
update installs on the next quit. Everything the updater does is appended to updater.log in the app’s
data folder, which is where a failed update is diagnosable instead of silent.
When the app attaches instead of starting
Section titled “When the app attaches instead of starting”When you launch the desktop app while a daemon already owns :7777, the app attaches to that
process rather than starting its own, and says so only in its console. The window still shows your
latest UI (the engine serves the bundle from disk), so the symptom is narrow and confusing: UI changes
apply, engine changes don’t. The tray’s Restart Engine is the fix.
To find out which process is actually serving:
lsof -ti tcp:7777 # the pidps -o command= -p <pid> # …and what it isA launchd-owned engine shows up with the tsx CLI in its command line and, because KeepAlive is on,
restarts if you kill it directly — bootout or daemon uninstall first if you want it gone.
The rest of the CLI
Section titled “The rest of the CLI”daemon is one of four commands on the same entry point, and the others are what a service-shaped
LatchAI is usually driven with:
npx tsx packages/engine/src/cli.ts serve # the engine, in the foregroundnpx tsx packages/engine/src/cli.ts run workflows/nightly.json # one headless run, events to stdoutnpx tsx packages/engine/src/cli.ts tools # every registered tool, grouped, plus MCP statusA headless run is a manual run: it fires the graph’s manual_trigger when it has one, exactly as
▶ Run does, and everything after the workflow file becomes the run payload.
What this doesn’t cover
Section titled “What this doesn’t cover”The launchd integration is macOS-specific. Running the engine as a service on Linux — a systemd unit around the same command — is not documented here yet.