Skip to content

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

bash
curl -fsSL https://raw.githubusercontent.com/lrivallain/precursor/main/scripts/install.sh | sh

That 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?

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

bash
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 items

status 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/.env

Recording 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

bash
precursor tray

The icon is Precursor's own mark, and it says what the instance is doing:

IconMeans
Brand colourthe instance is running
Greyit is stopped
Grey, with an ellipsis in the bubblebusy — 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
BulletStatus 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:

EntryDoes
Open Precursoropens the running instance in your browser
Reveal data folder in Finder¹opens the data directory in your file manager
Open log fileopens the instance log — falls back to the logs folder when there isn't one yet
Start / Stop / Restartthe supervisor actions above
Check for updatesbecomes "Update to … and restart" once a newer build exists
Quit traycloses 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_NOTIFYBehaviour
prompt (default)a notification with buttons, where the desktop supports them
notifya plain toast — no buttons, nothing to dismiss
offsay 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:

bash
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" opens

The tray needs the tray extra (pystray + Pillow), which the install script includes by default:

bash
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

bash
precursor service check    # is there something newer?
precursor service update   # install it and restart

Precursor is installed in one of two shapes and each updates differently, so the command detects which one you have rather than asking:

InstallDetected asUpdated by
uv tool install precursor-aiuv-toolreinstalling the published wheel
A clone you run with uv run precursorsourcegit 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

ChannelTracksChosen when
nightlya rolling prerelease built from every push to mainyou're running a dev build
stablethe latest tagged releaseyou'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.

bash
precursor service logs -n 100   # the same file the tray's "Open log file" opens

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

FileWritten by
precursor.logthe app (rotating; precursor.log.1, … are its generations)
tray.logthe menu-bar icon, which is a separate process with its own failures
precursor.out.logthe raw pipe of a supervisor-started child, capped at each start
launchd.app.err.log, launchd.tray.err.loglaunchd, 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 asDatabaseData 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:

bash
PRECURSOR_GITHUB_CLI_USER=your_login

Precursor then asks for that account specifically, every time. A token saved in Settings → GitHub still wins over the CLI, as before.

See also

Released under the MIT License.