# Application: Mosaic API reference

> Application in Mosaic’s local REST API: 16 routes, each with its method, path, fields, what it returns and the errors it names.

Source: https://mosaicbrowser.com/docs/api/application

**System** REST API

## Application

Health, engines, settings and the MCP server. Three groups of the desktop app’s own actions are deliberately *not* here: minting and revoking API keys, because authenticating with a key to mint more keys defeats the point of having them; opening files and folders on the host, because a remote caller asking the machine to open a path is a request to run something, not to manage a profile; and native file dialogs, which have nobody in front of them to answer.

- [GETIs there a newer Mosaic](#updates-check)
- [POSTFetch and verify a newer version](#updates-download)
- [POSTRestart into the downloaded version](#updates-apply)
- [GETWhat is wrong with this library](#overview)
- [GETVersion, platform and where the data lives](#app-info)
- [POSTRestart the MCP server](#mcp-restart)
- [GETHealth check](#health)
- [GETStream profile status changes](#events)
- [GETWhat the API is doing right now](#api-activity)
- [GETList browser engines](#list-engines)
- [GETWhat the engine manifest offers](#engines-available)
- [POSTInstall the engine](#engines-install)
- [GETThe plan this install is on](#get-account)
- [DELETEDelete the account, for good](#delete-account)
- [GETRead settings](#get-settings)
- [PATCHUpdate settings](#update-settings)

## [Is there a newer Mosaic](#updates-check)

GET`/api/v1/updates`

Reads the release manifest at `downloads.mosaicbrowser.com/<channel>/latest-mac.json` (`latest-win.json` on Windows) — public, **no credential** — and returns the updater's whole state, the same object the window draws. `phase` is one of `idle`, `checking`, `up-to-date`, `available`, `downloading`, `ready`, `installing`, `error`. **A check that could not run is reported as itself** in `error`, never as `available: false`: `error.quiet` is true when it was only the network — offline, a timeout, a server that did not answer, a channel with nothing published — which is retried by itself and never shown as an alert. `replaceable` and `blocked` are a separate question from whether an update exists: a development build, one running outside `/Applications`, or a Windows copy unpacked from the portable zip rather than installed, is told the truth about what exists and why it cannot install it in place. With `updateAutoDownload` on, a found update starts downloading in the background. `PATCH /settings` sets `updateCheck` (`auto`, `manual`, `off`), `updateAutoDownload` and `updateChannel`.

#### Query parameters

| Field | Type | What it is |
| --- | --- | --- |
| `cached` | `boolean` | `true` reads the state without contacting anything. Default `false`. |

#### Returns

`{ phase, current, channel, latest: { version, notes, publishedAt, size } | null, progress, error: { id, stage, title, detail, quiet } | null, lastCheckedAt, restartWhenIdle, runningProfiles, replaceable, blocked, autoDownload, checkMode, installerUrl }`

#### Errors

- `400` — `cached` is not `true` or `false`.

## [Fetch and verify a newer version](#updates-download)

POST`/api/v1/updates/download`

Downloads the build for this platform and architecture and installs **nothing**. The archive is hashed as it streams and compared with the SHA-256 in the manifest *before* it is unpacked or run; the download is asked its own version and has to agree (the bundle's `Info.plist` on macOS, the installer's version resource on Windows); and when the running copy is Developer-ID signed, the new one must be signed by the same team (`GK97ADS394`) and pass `codesign --verify --deep --strict`. Windows builds are not code-signed, so there the checksum is the proof. Allowed while profiles run — downloading touches nothing they use; restarting is where they are counted. Progress is in `GET /updates`.

#### Returns

`{ version, bytes }`

#### Errors

- `400` — this build is not an installed application, there is no newer version, the server could not be reached, or the archive failed its checksum, version or signature check (nothing is installed).

## [Restart into the downloaded version](#updates-apply)

POST`/api/v1/updates/apply`

Swaps the downloaded bundle in and relaunches Mosaic. **It never restarts under a running profile.** With browsers open, `when: "now"` is refused with the count unless `stopProfiles: true` asks for them to be stopped first, each through the browser's own exit; `when: "when-idle"` records the wish and Mosaic restarts by itself once the last profile closes; `when: "cancel"` withdraws it. **The response is sent before the process ends**: a caller that got nothing back cannot tell a restart from a crash. On macOS the old application is moved aside rather than deleted and swept on the next start, and a swap that fails puts it back. On Windows Mosaic starts the verified installer silently (`/S --updated --force-run`) and exits; the installer replaces the install where it is and starts the new version. The data directory is never touched on either.

#### Body

| Field | Type | What it is |
| --- | --- | --- |
| `when` | `"now" \| "when-idle" \| "cancel"` | Default `now`. |
| `stopProfiles` | `boolean` | With `when: "now"`, stop running profiles first. Default `false`. |

#### Returns

`{ version, restarting, waitingFor?, cancelled? }`

#### Errors

- `400` — nothing has been downloaded, profiles are running and neither `when-idle` nor `stopProfiles` was sent, an unknown option, or this build is not an installed application.

## [What is wrong with this library](#overview)

GET`/api/v1/overview`

One pass over every profile and proxy, returning the counts a dashboard needs **and the ids behind each one**, so a caller can act without going looking. The three findings worth acting on: profiles claiming a timezone their proxy's measured exit contradicts — which is the first comparison every consistency scanner makes and drifts on its own whenever a pool re-homes a subnet; profiles describing a machine nobody sells, which a detector holding a table of real devices reads as masking; and proxies that are unreachable, refusing `CONNECT`, blocklisted or rotating. `no-proxy` and `never-run` are marked `info` because neither is a fault — they are facts worth knowing, since direct profiles all share one address and an empty cookie jar is itself scored as risk. Auditing needs the whole fingerprint, so this reads every profile off disk: call it when a screen opens, not on a timer. **Scope it to what you are looking at.** `projectId` and `os` narrow the pass to one project and one set of platforms — the same two filters `GET /profiles` takes — and the response echoes them in `scope`. Unscoped it counts every project, which is right for “what is wrong anywhere” and wrong for anything rendered over one project’s list: the app’s own health bar used to do exactly that and named profiles the table under it could not show.

#### Query parameters

| Field | Type | What it is |
| --- | --- | --- |
| `projectId` | `string` | Count only this project. Absent counts every project. |
| `os` | `string` | Comma-separated platforms to count: `windows`, `macos`, `linux`, `android`. An unknown one is a `400` naming the valid set. |

#### Returns

`{ profiles: { total, running, neverRun, inTrash }, proxies: { total, live, warn, down, untested }, issues: [{ id, subject: "profile"|"proxy", severity: "high"|"medium"|"info", title, summary, detail, count, ids[], names[] }], scope: { projectId, os }, checkedAt }` — `summary` is the fault in a few words with no count in it, and `names[i]` is `ids[i]`.

## [Version, platform and where the data lives](#app-info)

GET`/api/v1/app`

The same facts the Settings page shows: version, platform, architecture, the resolved `userData` directory, and whether the MCP server is up. Useful for a script that needs to find the profile store on disk without guessing at platform conventions. `hostLanguage` is the tag this machine's ICU resolves to — on a stock Chromium engine it is the only value `Intl` will ever report, so a profile whose `locale.languages` does not start with it is claiming a language its own date and number formatting contradicts.

#### Returns

`{ version, platform, arch, dataDir, apiRunning, mcpRunning, hostLanguage }`

## [Restart the MCP server](#mcp-restart)

POST`/api/v1/mcp/restart`

Stops the MCP server and starts it again on the port in settings, or leaves it stopped if `mcpEnabled` is false. Changing `mcpPort` through `PATCH /settings` does not move a running server — this is what applies it. Agents connected over MCP are disconnected and must reconnect.

#### Returns

`{ running, port }`

## [Health check](#health)

GET`/api/v1/health`

The one endpoint that does not require a key, so a supervisor can check the API is up without holding a credential. It is also the only one that answers while nobody is signed in to Mosaic: every profile, proxy, setting and API key belongs to an account, so signed out every other route — and every MCP request — is `401` with `{ error, signIn: true }` and a sentence saying to sign in, before any key is looked at. `signedIn` says which of the two you will get.

#### Returns

`{ ok: true, name: "mosaic", apiVersion: 1, signedIn }`

## [Stream profile status changes](#events)

GET`/api/v1/events`

A **server-sent event stream** that stays open and reports profile status as it changes, so an automation client does not have to poll `/running` on a timer. The first message is always `running`, carrying everything running at the moment you connected — without it a client that attached mid-session would know nothing until the next change. After that: a `profile` event whenever any profile starts, becomes ready, stops or errors, and a `retired` event when a one-time profile deletes itself after its run. A `: keep-alive` comment every 25 seconds keeps intermediaries from reaping an idle connection; SSE ignores comment lines, and `EventSource` reconnects on its own if one is dropped anyway. The response never ends, so do not `await` it as an ordinary request.

#### Returns

A `text/event-stream`. Events: `running` `{ profiles: RunningProfile[] }`, `profile` `RunningProfile`, `retired` `{ profileId }`.

```
curl -N -H "Authorization: Bearer $KEY" "$BASE/events"
```

## [What the API is doing right now](#api-activity)

GET`/api/v1/activity`

Everything happening from outside this window in the last five minutes, which is what the interface’s own **API activity** page renders. Four parts. `callers` is which keys have called, by the name their owner gave them, with what each last called and how many calls it has made inside the window — REST and MCP are reported separately, because the same key used from a script and from an assistant is two things doing two jobs. `runningUnderApi` is how many of the profiles those calls touched are running right now. `profiles` is every profile the API has touched, **flat and newest first**. Each entry is a whole `ProfileSummary` — the same row `GET /profiles` returns, so a client rendering a table needs no second request per profile — alongside what the API last did to it, which key did it (`keyId`, `keyName`) and which project it is in (`projectId`, `projectName`). Flat rather than grouped by project, because grouping is a rendering decision with more than one useful answer: the interface groups by project *after* filtering to a single key, and a client watching one project wants the opposite. Two keys working in the same project is the case any project-first shape cannot express without listing the project twice. A profile the API deleted still appears, rebuilt from the action that recorded it and marked `gone`; a project the API deleted keeps the name it had, because the name is recorded on the action rather than looked up afterwards. `profiles` and `actions` cover the **same** retained events — they used to use different windows, so the feed could name a profile the profile list had already dropped. `actions` is what was **done**, newest first, in words rather than routes — `Created profile`, `Attached proxy` — and reads are left out entirely, because a client polling `/running` once a second would otherwise bury the one line that says a profile was deleted. Actions are kept in memory and capped, so the list starts empty after a restart. A script can use this to check whether another key is already working on something before it starts fighting it. **Never returns a key**, only its id and name; the secret is hashed in the store and does not exist in the process in any readable form. Reading this route does not itself count as activity.

#### Returns

`{ windowSeconds, callers: [{ keyId, keyName, surface, lastAt, lastCall, calls }], runningUnderApi, profiles: [{ profile: ProfileSummary, running, lastVerb, lastAt, gone, keyId, keyName, projectId, projectName, projectGone }], actions: [{ id, keyId, keyName, surface, at, verb, subject, profileId, projectId, projectName }] }`

```
curl -H "Authorization: Bearer $KEY" "$BASE/activity"
```

## [List browser engines](#list-engines)

GET`/api/v1/engines`

The browser on this machine: whether it is installed, its version, and `source` — `bundled` (the copy inside the app, which every release carries) or `installed` (a newer one downloaded as an engine update; the newer of the two is the one profiles run). `install` is the download in progress, if any, and the last failure — a launch that answered `503` because the browser was missing can watch it arrive here. `install.update` is whether a newer engine is published, as the last check found it — Mosaic looks shortly after starting and every four hours while `updateCheck` is `auto`, and every `GET /engines/available` refreshes it. An engine update is its own channel, published apart from app releases, and **optional**: it is offered (here, in Settings → Engine, and as one line in the sidebar) and never installed on its own.

#### Returns

`{ total, engines: EngineInfo[], install: { installing, lastError, update: { current, latest, size, available, checkedAt, error } | null } }`

## [What the engine manifest offers](#engines-available)

GET`/api/v1/engines/available`

Reads and validates the published browser’s manifest and answers which of its builds this machine can take, and whether it is newer than the one in use (`updateAvailable`, against `current`). Nothing is downloaded. The source is `engineManifestUrl` when it is set, otherwise the Mosaic account server, which is sent your session: a signed-out account gets a `400` saying to sign in; every plan, Free included, gets the browser, and any other refusal from the server is passed through as it said it. A self-hosted or loopback manifest (your own build) is never gated. The manifest itself is refused, with the field named, if any value is missing, unknown, not https, or not a 64-character hex checksum.

#### Returns

`{ source, manifest, forHost, note, current, updateAvailable }`

## [Install the engine](#engines-install)

POST`/api/v1/engines/install`

An engine update, independent of the app: downloads the build for this machine into the data directory, verifies its SHA-256 before unpacking anything, checks the unpacked binary reports the version the manifest claims, and only then swaps it into place. About a gigabyte. The copy bundled with the app is never touched; the download is used from the next launch because it is newer. Gated like `GET /engines/available`; the build URL the account server hands back is a short-lived signed link, so it cannot be shared. `409` while a profile is running on a previously downloaded copy, because that is the one being replaced. A second call while a download runs waits on that download rather than starting another. A download host that fails — unreachable, a non-200, a checksum that does not match — is a `502` carrying the sentence.

#### Returns

`{ version, executablePath, bytes }`

## [The plan this install is on](#get-account)

GET`/api/v1/account`

Free or Pro, what the plan allows and how much of it is in use. `limits.profiles`, `limits.creations` (profiles made in all, counted by the account server per account and per computer) and `limits.projects` are numbers on Free and `null` (unlimited) on Pro — on Free they are this account’s own: `freeTier` is `current` (3 profiles, 10 made in all) or `legacy` for an account made before the Free plan shrank on 2026-11-03, which keeps 5 and 20; `limits.devices` is how many computers the account may be signed in on; `limits.api` and `limits.mcp` say whether those surfaces answer an API key. `usage.creations` is the account server's count as of the last time it was asked, or `null` when it does not apply or has not been read. `source` says *why*: `subscription` (a verified, paid entitlement), `free` (signed out, or signed in without paying), `expired` (a paid plan that was not renewed in time), or `unconfigured` (a build with no account server, which limits nothing). `billing` says what pays for Pro: `card` (a Stripe subscription, `periodEnd` is when it renews or ends) or `crypto` (a period paid ahead — `periodEnd` is its paid-until, nothing renews, and it stays set after the period ends so the end can be named); `null` for neither. `interval` is `month` or `year` for a card subscription. `status` reads `paused` while a break the customer took is in effect — Pro is withheld, the profiles are kept — and `resumesAt` says when billing and Pro come back. Pausing, resuming, changing interval and cancelling happen on the account page at mosaicbrowser.com. `trustedUntil` is how long the current answer holds offline before Mosaic must ask the account server again. Never carries a session token or the signed entitlement. Read-only: signing in and paying happen in the Mosaic window. **The REST API itself is part of Pro** — on Free every request with an API key is answered `402` with `{ error, feature: "api" }`, and the in-browser companion keeps working because it does not use an API key.

#### Returns

An `AccountState`: `{ configured, plan, limits, source, signedIn, email, status, periodEnd, cancelAtPeriodEnd, billing, freeTier, interval, resumesAt, checkedAt, trustedUntil, lastError, usage: { profiles, projects, creations } }`.

## [Delete the account, for good](#delete-account)

DELETE`/api/v1/account`

Deletes the Mosaic account this install is signed in to, at the account server, and signs this Mosaic out. Irreversible: the account, its sessions on every computer and its support history go, and a live subscription is cancelled first so the card is not charged again. Every browser is stopped first (one that will not close is named and nothing is deleted), and Mosaic then restarts on its sign-in screen. **The account’s profiles can never be opened again:** they stay on this disk, encrypted, but the key that opened them was the account’s, and it is deleted with it — profiles, cookies and proxies were never on the server, so there is no copy anywhere else. This computer keeps its count of profiles made, so a new account here starts where the old one stopped. The body is `{ "password": "…" }`, every time: an absent password is a `400` naming it, a wrong one is a `400` that deletes nothing, any other field is refused by name. `400` when not signed in; the operator's root account cannot be deleted from the app.

#### Body

| Field | Type | What it is |
| --- | --- | --- |
| `password`required | `string` | The account's password, typed again: the confirmation. |

#### Returns

`{ deleted: true, account }` — `account` is the `AccountState` after signing out.

#### Errors

- 400 — `password` missing or wrong (nothing deleted), an unknown field, or not signed in

## [Read settings](#get-settings)

GET`/api/v1/settings`

Current application settings.

#### Returns

An `AppSettings`.

## [Update settings](#update-settings)

PATCH`/api/v1/settings`

Merges the fields you send, after checking every one of them. A value of the wrong type or outside its range is refused rather than stored — `apiPort` must be a whole number between 1 and 65535, `theme` must be `system` (follows the operating system and switches when it does; the default), `light` or `dark`, `trashRetentionDays` at least 1. `defaultEngineId` is `mosaic` — Mosaic builds Tesseract Chrome and profiles run it, and the interface offers no other choice, because every value a stock browser can only move in JavaScript is one a service worker reads the true answer for. An unbuilt engine is a refusal naming it rather than a quiet fall-through to whatever else is installed. `perProfileDockIcon` is macOS-only and **off by default**: with it on, every visible launch runs under a copy of the browser dressed as the profile’s project, so the Dock shows which window belongs to what instead of a row of identical browser tiles. The cost is a macOS one and cannot be prevented from inside the bundle — the system registers a bundle identifier it has never seen as a newly installed application and greets it with a notification banner, once per identifier, for ever. Eleven measured attempts to suppress that (the Alerts helper removed, `--disable-notifications`, `NSUserNotificationAlertStyle` set to none and deleted outright, and combinations) all still registered. So the identities are pooled by window slot, which bounds the banners at your peak number of open browsers rather than your number of profiles — and the setting is off unless you ask for it. Off-screen and headless launches skip it regardless, so checks and warm-up runs mint nothing. Setting it to `false` also deletes the copies already on disk. An **unknown key is also refused**, and the error lists the settings that do exist, so a misspelled field is a `400` instead of a silent no-op that reports success. `downloadMode` decides where a download lands and where a file picker opens. `"shared"` is one folder for every profile — `downloadDir`, or `null` for the operating system’s own Downloads folder — which is what every browser does and what this app did for its whole life: cookies and history are separated because a site must not see across them, and a download is a file the operator asked for. `"perProfile"` (the default) gives each profile its own folder inside Mosaic’s data directory, opens that profile’s file picker there, deletes the files when the profile is purged, and makes them reportable and wipeable through `/downloads`. It is **not encryption** — Chromium writes the bytes itself — but it keeps the files out of the home folder and out of Spotlight. An unknown value is a `400` naming the field rather than a quiet fall-through, because a caller told their downloads are isolated when they are not is the worst answer this API could give. A change to either takes effect at each profile’s next launch; a running browser keeps the folder it resolved at startup. `profileColumns` is which optional columns the profile table shows — any of `tags`, `proxy`, `timezone`, `created`, `lastRun`, stored in that order whatever order they are sent in; a name that is not a column is a `400` naming it. Changing the API bind address or port needs a server restart to take effect. `inspect` is `{ enabled, address }` and routes **every launch from now on** through a proxy that can read its traffic — Proxyman, Charles, mitmproxy, Fiddler, Burp. Both fields are required together and an unknown field inside it is refused by name; `address` is `host:port` (an `http://` prefix is accepted and dropped) and is stored normalised. It is a debugging switch and not a privacy one: whatever is listening sees every request in plaintext. A profile **with a proxy of its own** needs the inspector forwarding back to Mosaic — the chain has to be browser → inspector → Mosaic → the exit — and **Mosaic arranges that itself**: it holds a relay on `127.0.0.1:39219` for as long as inspection is on and points the inspector at it through the inspector’s own command line, putting it back when you switch off. That relay forwards *direct* whenever no profile is using it, so nothing else you are watching through the inspector breaks. The chain is still measured before each window opens — by counting whether the traffic actually passed through Mosaic, which holds for rotating exits where comparing addresses cannot — and a launch is refused with a `502` rather than sent out of this computer’s address. Automatic configuration is Proxyman-only today; elsewhere the address is typed in and the refusal names the upstream to set. The one thing left to a person either way is trusting the inspector’s CA certificate, without which HTTPS pages fail to load.

#### Body

`Partial<AppSettings>`. Any subset of settings. Unknown keys are rejected.

#### Returns

The updated `AppSettings`.

#### Errors

- `400` — a value of the wrong type or out of range, or a key that is not a setting.

## Run the API on your own profiles

The REST API and the MCP server are part of Pro: $49 a month, with the first 14 days free. Free has every browser feature and no API key.

Mosaic is a desktop app

Email yourself the link and open it on your computer.
