Background app
Precursor started life as a thing you run in a terminal, and for development that is still the right shape. But an assistant you consult a dozen times a day shouldn't need a terminal window kept open, a directory to be cd'd into, or a four-command ritual after every reboot.
The background app is the answer: one install, a login item, and a menu-bar icon that tells you whether it's up — plus a self-update that replaces the git pull you used to run by hand.
One command to install
curl -fsSL https://raw.githubusercontent.com/lrivallain/precursor/main/scripts/install.sh | shThat installs the latest build, registers it to start when you log in, and starts it now. There is no clone, no npm, and no build step — the published wheel already carries the SPA, the in-app docs and every plugin frontend.
What it actually runs
uv tool install against the wheel for your channel, then precursor service install. Both are ordinary commands you can run yourself; the script only saves you looking up the current wheel URL.
Prefer stable, tagged releases over the rolling build from main?
PRECURSOR_CHANNEL=stable sh -c "$(curl -fsSL https://raw.githubusercontent.com/lrivallain/precursor/main/scripts/install.sh)"Managing the instance
Everything the tray can do has a command behind it, so the app is never dependent on a GUI being present:
precursor service status # is it running, on which port, since when
precursor service start # start a detached instance
precursor service stop # stop it
precursor service restart # bounce it (keeps the port it was on)
precursor service logs -n 100 # tail the instance log (see Reading the log)
precursor service data-dir # print the data directory (--reveal opens it)
precursor service install # run at login (app + tray) and start now
precursor service uninstall # remove the login itemsstatus exits non-zero when nothing is running, so it drops straight into a shell prompt or a monitoring check. Add --json for a machine-readable form.
Picking a port
Installing is most people's first run, and 8000 is a popular port — another dev server holding it must not be the thing that makes an install fail.
So precursor service install settles the port before it registers the login item: if the configured one is busy it takes the next free port and writes it to .env in the data directory, then reports both.
Port 8000 is in use — Precursor will run on 8001 instead.
Port 8001 saved to /home/you/.local/share/precursor/.envRecording it is what makes the choice stick. launchd and systemd start the login item with no arguments, so the port has to live in a file the instance re-reads on its own — otherwise every boot would read the busy default straight back, and the URL would drift with whatever else happened to be listening that day.
A port you chose — PRECURSOR_PORT in the environment or in that .env, or precursor service install --port 8123 — is never moved silently: if it is taken, the install stops and says so, because a deliberate choice deserves an error rather than a different URL.
How it knows
The supervisor records the instance it started in runtime.json inside the data directory — pid, host, port, URL, version. Everything else is derived from that file plus a liveness probe, so there is one source of truth and nothing has to guess a port.
If the process dies (a crash, a hard reboot), the next status notices the pid is gone and clears the record rather than leaving a file that claims a port something else may now own.
Starting is idempotent: a login item, a tray click and a manual precursor service start can all race, and none of them ends up with two instances fighting over one database.
The menu-bar icon
precursor trayThe icon is Precursor's own mark, and it says what the instance is doing:
| Icon | Means |
|---|---|
| Brand colour | the instance is running |
| Grey | it is stopped |
| Grey, with an ellipsis in the bubble | busy — starting, stopping, or updating |
Running and stopped are the same silhouette in different colours, so the icon never looks like a different app. Busy is the one state that changes the glyph: grey alone would read as "stopped", and mid-update the app may still be answering on its old port — an icon that looks ready while its own code is being replaced is a lie worth avoiding.
The menu leads with two lines you can't click: what the instance is doing, and where this install stands.
Precursor — running on :8000
🟢 Up to date — 2026.9.0| Bullet | Status line |
|---|---|
| 🟢 | Up to date — the last check found nothing newer |
| 🟡 | Update available — with the version waiting |
| 🔴 | Could not check for updates — offline, rate limited |
| ⚪ | Checking…, or Update checks are off (--no-update-check) |
"Couldn't check" and "up to date" are different facts, and conflating them is how an install goes quietly stale — so they get different colours.
Then the actions:
| Entry | Does |
|---|---|
| Open Precursor | opens the running instance in your browser |
| Reveal data folder in Finder¹ | opens the data directory in your file manager |
| Open log file | opens the instance log — falls back to the logs folder when there isn't one yet |
| Start / Stop / Restart | the supervisor actions above |
| Check for updates | becomes "Update to … and restart" once a newer build exists |
| Quit tray | closes the icon only — Precursor keeps running |
¹ Named for the platform: Show data folder in Explorer on Windows, Open data folder elsewhere.
The icon can tell when it is the stale one
precursor.__version__ is resolved once, when the process starts, so a long-running icon would otherwise go on comparing the release it was started with against the published build — and keep offering an update that is already installed. The instance is a fresh process, and its runtime record carries the version it actually launched with, so a disagreement means the icon is behind. When that happens the update entry becomes "Restart the icon (running an older build)", which bounces the tray's login item and drops the cached check.
This is what catches an update that never went through precursor service update — a manual uv tool install --force, say — where nothing would otherwise have told the icon to restart.
Being told about a new build
The tray checks for updates in the background, and a check nobody asked for is exactly the one worth speaking up about — otherwise the menu sits there knowing about a new build until you next happen to click the icon.
So when a background check finds one, Precursor raises a notification with an Update and restart button: taking the update is one click, from wherever you were. Anything else — Later, dismissing it, or letting it time out — leaves the build waiting in the menu; only an explicit yes restarts anything.
Each build is announced once. A poll every half hour must not become an interruption every half hour.
PRECURSOR_UPDATE_NOTIFY | Behaviour |
|---|---|
prompt (default) | a notification with buttons, where the desktop supports them |
notify | a plain toast — no buttons, nothing to dismiss |
off | say nothing; the menu's status line still shows 🟡 |
Buttons need something on the desktop that can draw them: macOS uses osascript, Linux uses notify-send --action (libnotify 0.8+). Where neither is available — Windows, a bare session — the announcement degrades to a plain toast rather than disappearing.
The data-folder entry is deliberately not disabled while the instance is stopped: the database and the logs are exactly what you want to reach when it won't start. It also creates the directory if a fresh install hasn't written it yet, since an empty folder beats a file-manager error. The same thing from a shell:
precursor service data-dir # print the path
precursor service data-dir --reveal # open it in the file manager
cd "$(precursor service data-dir)" # it composes
precursor service logs -n 100 # what "Open log file" opensThe tray needs the tray extra (pystray + Pillow), which the install script includes by default:
uv tool install --force "precursor-ai[kanban,tray]"Keep the tray running too
The tray is a separate, disposable process — quitting it does not stop Precursor. precursor service install registers it as its own login item alongside the app, so the icon comes back after a reboot; pass --no-tray to register only the app. On a machine without the tray extra it is skipped automatically, rather than leaving a login item that fails every boot.
Updating in place
precursor service check # is there something newer?
precursor service update # install it and restartPrecursor is installed in one of two shapes and each updates differently, so the command detects which one you have rather than asking:
| Install | Detected as | Updated by |
|---|---|---|
uv tool install precursor-ai | uv-tool | reinstalling the published wheel |
A clone you run with uv run precursor | source | git pull --ff-only + a plugin frontend rebuild |
The app also exposes the read-only check at GET /api/version/check. Applying an update is deliberately not an API call: it replaces the very process serving the request, so it belongs to the supervisor.
Channels
| Channel | Tracks | Chosen when |
|---|---|---|
nightly | a rolling prerelease built from every push to main | you're running a dev build |
stable | the latest tagged release | you're running a tagged version |
Pin one explicitly with PRECURSOR_UPDATE_CHANNEL.
A nightly build isn't ordered — two branches can share a base version — so the nightly channel compares the commit rather than the version number, and a nightly host is installed together with the plugin wheels built from the same commit instead of whatever is on PyPI.
Extras, and what happens when one can't be resolved
A uv tool install is reinstalled with the extras it already has — read from uv's install receipt rather than from configuration, so an update can't quietly uninstall the menu-bar icon you asked for. PRECURSOR_UPDATE_EXTRAS adds to that list; a -name entry removes from it.
An extra can fail to resolve — a plugin published an hour ago that your index hasn't ingested, or a mirror that doesn't carry it at all. When it does, the update retries once without the extras that only pull a Precursor plugin, and tells you:
Installed 2026.9.0. Skipped kanban — not installable from your index: …A plugin is optional by construction, so it shouldn't be able to hold the host on an old build. Extras that pull libraries the app itself uses (tray, postgres) are never dropped, and a failure that survives the retry is reported as a failure. The next update tries the plugin again; use PRECURSOR_UPDATE_EXTRAS=-kanban to stop asking for good.
Reading the log
precursor.log, in logs/ under the data directory, is written by the running app itself — through a size-rotating handler it configures at startup, not by whoever happens to own the process's stdio.
That distinction is the whole point. Precursor runs under three different owners: a terminal, its own supervisor (precursor service start), and a launchd agent or systemd user unit. Only the middle one has Precursor holding the pipe. So a log written by the pipe would go stale the moment you ran precursor service install — which is exactly what used to happen, silently, while service status went on advertising the file.
precursor service logs -n 100 # the same file the tray's "Open log file" opensRotation matters for the same reason: a service manager's own capture is unbounded, and nothing ever prunes it. PRECURSOR_LOG_FILE_MAX_BYTES (5 MB) and PRECURSOR_LOG_FILE_BACKUPS (3) cap it here instead.
The other files in logs/
precursor.log is the log. The rest are raw stdio nets, holding only what logging can't catch — an import error, a traceback from before logging was configured, a native crash:
| File | Written by |
|---|---|
precursor.log | the app (rotating; precursor.log.1, … are its generations) |
tray.log | the menu-bar icon, which is a separate process with its own failures |
precursor.out.log | the raw pipe of a supervisor-started child, capped at each start |
launchd.app.err.log, launchd.tray.err.log | launchd, for the login items (macOS) |
On Linux the launchd files have no equivalent: systemd captures stderr to the journal (journalctl --user -u precursor).
When the app is started by a service manager its stderr is already being captured, so the console handler is dropped — otherwise every line would be written twice, and only one of the two copies would ever be rotated.
Where the data lives
This is the part that makes a launcher-started app work at all. A login item runs with its working directory set to /, so the old relative defaults would have created a fresh, empty database wherever it happened to start.
So the defaults now depend on how Precursor was installed:
| Installed as | Database | Data directory |
|---|---|---|
| A source checkout | ./precursor.db | ./.precursor |
| A wheel | <user data dir>/precursor.db | <user data dir> |
…where user data dir is ~/Library/Application Support/Precursor on macOS, $XDG_DATA_HOME/precursor (or ~/.local/share/precursor) on Linux, and %APPDATA%\Precursor on Windows. PRECURSOR_DATABASE_URL and PRECURSOR_DATA_DIR still override both.
Development is untouched
Keeping the checkout defaults relative is what preserves the worktree workflow: every clone keeps its own database beside its code, precursor --dev still auto-bumps to a free port, and none of it can collide with the installed instance — which supervises only the data directory it was configured with.
Pinning the GitHub account
If you keep several accounts signed in to the GitHub CLI, gh auth token follows whichever one is active — so the token Precursor gets depends on whoever last ran gh auth switch in an unrelated shell. That's fine interactively and hopeless for something started at login.
Name the login instead:
PRECURSOR_GITHUB_CLI_USER=your_loginPrecursor then asks for that account specifically, every time. A token saved in Settings → GitHub still wins over the CLI, as before.
See also
- Installation — the full set of install options
- Configuration reference — every variable involved
- Storage & retention — what accumulates in the data directory