Skip to content

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 engine in 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.

The engine CLI writes and loads the LaunchAgent for you:

Terminal window
npx tsx packages/engine/src/cli.ts daemon install
npx tsx packages/engine/src/cli.ts daemon status
npx tsx packages/engine/src/cli.ts daemon uninstall

install 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.

~/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.

Terminal window
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 answering
tail -f ~/LatchAI/runs/daemon.log # everything it prints

daemon.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.

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.

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:

Terminal window
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.

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 serve command installs process-level handlers that log the full stack and keep running. They are installed only on serve: 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>.jsonl is complete rather than stopping several events short of what the run actually did.

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 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:

Terminal window
lsof -ti tcp:7777 # the pid
ps -o command= -p <pid> # …and what it is

A 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.

daemon is one of four commands on the same entry point, and the others are what a service-shaped LatchAI is usually driven with:

Terminal window
npx tsx packages/engine/src/cli.ts serve # the engine, in the foreground
npx tsx packages/engine/src/cli.ts run workflows/nightly.json # one headless run, events to stdout
npx tsx packages/engine/src/cli.ts tools # every registered tool, grouped, plus MCP status

A 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.

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.