Skip to content

benchbar JSON API, schema version 1

benchbar (and its alias frappe-mac) prints versioned JSON for three commands, and the runner writes one state file per bench. The BenchBar app reads nothing else, so this is a public API: coding agents, scripts, Raycast extensions and the like can rely on it too.

SourceWhat it is
benchbar list --jsonevery bench benchbar knows about
benchbar status --json [--bench-dir DIR]the live state of one bench
benchbar doctor --json [--bench-dir DIR]every health check with its fix
benchbar app list --jsonthe bench’s apps, their git state and sites (0.5)
benchbar app update NAME --dry-run --jsonthe changelog and plan of an update (0.5)
benchbar profile list --jsonbuilt in and team profiles, with where each comes from (0.5)
benchbar lock check --jsonhow the bench differs from its benchbar.toml (0.5)
<bench>/logs/.benchbar/state.jsonthe last state transition, written by the runner and the CLI
benchbar pull ... --jsonJSON lines while a production site is copied, see pull
benchbar report --jsonwhere the redacted diagnostics zip went (0.5.5), see report

Rules for readers

  • Every document has "schema_version": 1 and "cli_version" at the top. The schema version goes up only for a change that breaks readers: removing or renaming a field, or changing its type or meaning.
  • Adding fields is not a breaking change. Ignore fields you do not know.
  • Treat an enum value you do not know (a new state or stop_reason) as “unknown”, never as an error.
  • Timestamps are ISO 8601 in UTC with a Z suffix, for example 2026-09-23T10:00:00Z.
  • null means “not known” or “does not apply”. Numbers are JSON numbers.
  • JSON goes to stdout on one line. Errors and warnings go to stderr as text. Exit codes: 0 success, 1 failure (for doctor: at least one check is fail; the JSON is still printed). A missing bench exits 1 with a message on stderr and nothing on stdout.
  • status --json, list --json and doctor --json are read only. They never start, stop or write anything, so they are safe to poll.

States

stateMeaningstop_reason
stoppednot runningmanual after benchdown, null otherwise (never started, clean exit, restart in progress)
startingprocesses are up, the site does not answer yetnull
runningprocesses are up and the site answers HTTPnull
crashedhoncho exited with an error; launchd retries after 20 secondscrash
pausedauto-restart is off until benchupcrash (the crash guard tripped: 3 starts in 10 minutes) or broken (honcho or the env is missing: run benchbar repair)

stop_reason can be manual, crash, broken or null. broken is an addition to the original manual | crash | null contract: it tells a reader that repair is needed, not just benchup.

How status decides (live facts win over the state file):

  1. Processes of this bench are running: running when the site answers any HTTP code, otherwise starting. A bench started by hand with benchfg counts as running.
  2. The stop flag logs/.bench-stopped says manual: stopped.
  3. It says crash: paused with crash. Anything else: paused with broken.
  4. state.json says crashed: crashed (launchd is about to retry).
  5. Otherwise stopped with stop_reason: null.

benchbar list --json

{
"schema_version": 1,
"cli_version": "0.3.0",
"default_bench": "/Users/you/frappe-bench",
"benches": [
{
"path": "/Users/you/frappe-bench",
"name": "frappe-bench",
"site": "macdev",
"label": "com.benchbar.frappe-bench",
"web_url": "http://macdev:8000",
"ports": { "web": 8000, "socketio": 9000, "redis_queue": 11000, "redis_socketio": 13000, "redis_cache": 13000 },
"default": true,
"service_installed": true,
"state_file": "/Users/you/frappe-bench/logs/.benchbar/state.json"
}
]
}
FieldTypeNotes
default_benchstring or nullthe bench commands use without --bench-dir
benches[].pathstringabsolute path, the unique key of a bench
benches[].namestringfolder name, used in the agent label
benches[].sitestringdefault site
benches[].labelstringlaunchd label, com.benchbar.<name>
benches[].web_urlstringhttp://<site>:<web port>
benches[].portsobjectweb, socketio, redis_queue, redis_socketio, redis_cache. redis_socketio was added in 0.4: bench keeps it equal to redis_cache and frappe v15 and v16 never connect to it; older CLIs leave it out
benches[].sitesarrayadded in 0.4, see Sites
benches[].defaultboolsame as path == default_bench
benches[].service_installedboolthe agent plist exists
benches[].state_filestringwhere the runner writes state.json

Benches are found in this order, without duplicates: the remembered bench (.benchbar/state.env), the WorkingDirectory of every com.benchbar.* and com.frappe-mac.* agent, then ~/frappe-bench, ~/dev/frappe-bench and any ~/* or ~/dev/* folder with sites/common_site_config.json.

benchbar status --json

{
"schema_version": 1,
"cli_version": "0.3.0",
"bench": "/Users/you/frappe-bench",
"name": "frappe-bench",
"site": "macdev",
"label": "com.benchbar.frappe-bench",
"state": "running",
"stop_reason": null,
"pid": 4242,
"started_at": "2026-09-23T10:00:00Z",
"last_exit_code": 0,
"web_url": "http://macdev:8000",
"web_ping_code": 200,
"ports": { "web": 8000, "socketio": 9000, "redis_queue": 11000, "redis_socketio": 13000, "redis_cache": 13000 },
"state_file": "/Users/you/frappe-bench/logs/.benchbar/state.json",
"log": "/Users/you/frappe-bench/logs/bench.log",
"agent_loaded": true,
"agent_state": "running",
"processes_running": true,
"url": "http://macdev:8000",
"agent": "com.benchbar.frappe-bench",
"loaded": "yes",
"stop_flag": "none",
"ping": "200"
}
FieldTypeNotes
benchstringabsolute path of the bench
name, site, labelstringas in list
statestringsee States
stop_reasonstring or nullsee States
pidnumber or nullthe runner process launchd started; honcho and every bench process are its descendants. Set while starting or running
started_atstring or nullwhen the current (or last) run started
last_exit_codenumber or nullexit code of the last run of honcho
web_urlstring
web_ping_codenumber or nullHTTP code of GET /api/method/ping with the site as Host; null when nothing answered within 3 seconds
portsobjectas in list
state_file, logstringpaths
agent_loadedboolthe launchd agent is loaded
agent_statestring or nulllaunchd’s own word, for example running or not running
processes_runningboolany honcho, serve, worker, socketio or port listener of this bench
sitesarrayadded in 0.4, see Sites
schedulerbooladded in 0.4: Procfile.lean runs bench schedule (benchbar service --with-schedule)

Kept from frappe-mac 0.2.0 for older readers: url, agent, loaded ("yes" or "no"), stop_flag ("manual", "crash", "broken" or "none"), ping (string, "000" for no answer). In 0.2.0 state held launchd’s word (now agent_state), and pid and last_exit_code were strings.

Sites

list --json (per bench), status --json and benchbar site list --json carry the bench’s sites, read from sites/*/site_config.json:

"sites": [
{ "name": "v16dev", "default": true, "hosts_entry": true, "ping_code": 200 },
{ "name": "v16two", "default": false, "hosts_entry": false, "ping_code": null }
]
FieldTypeNotes
namestringthe site folder
defaultboolthe site benchup waits for, the runner pings and the app opens; benchbar site default NAME changes it (and runs bench use)
hosts_entrybool/etc/hosts maps it to 127.0.0.1; benchbar site hosts adds the missing lines
ping_codenumber or nullHTTP code of /api/method/ping with this site as Host; null when nothing listens on the web port or nothing answered

benchbar site list --json prints {"schema_version":1,"cli_version":..,"bench":..,"sites":[..]}.

benchbar doctor --json

{
"schema_version": 1,
"cli_version": "0.3.0",
"bench": "/Users/you/frappe-bench",
"name": "frappe-bench",
"site": "macdev",
"profile": "v15-lts",
"checks": [
{
"id": "assets",
"group": "bench",
"label": "Built assets",
"level": "fail",
"message": "2 of 2 dist files referenced by assets.json are missing (site loads unstyled)",
"fix_command": "cd /Users/you/frappe-bench && bench build",
"action": "build",
"status": "fail",
"fix": "cd /Users/you/frappe-bench && bench build"
},
{
"id": "agent",
"group": "service",
"label": "launchd agent",
"level": "ok",
"message": "agent com.benchbar.frappe-bench loaded, running (pid 4242)",
"fix_command": null,
"action": null,
"status": "ok",
"fix": ""
}
],
"summary": { "ok": 19, "warn": 1, "fail": 1 }
}
FieldTypeNotes
checks[].idstringstable id, for example env_python, assets, agent, legacy_agents. pdf_engine replaced wkhtmltopdf in 0.4. 0.5 adds apps_txt, app_branch_policy, lock_parse and lock_drift (group bench, no repair action)
checks[].groupstringsystem, bench, service or site
checks[].labelstringshort name for humans
checks[].levelstringok, warn or fail
checks[].messagestringone line for humans
checks[].fix_commandstring or nullthe exact command a human would run; null when nothing needs doing
checks[].actionstring or nullthe repair action that fixes it, when repair can
summaryobjectcounts per level

status and fix are the 0.2.0 names of level and fix_command (with "" instead of null), kept for older readers.

benchbar logs --json

{"schema_version":1,"cli_version":"0.5.0","bench":"/Users/you/frappe-bench","file":"/Users/you/frappe-bench/logs/bench.log","process":"web","lines":["10:00:01 web.1 | * Running on http://127.0.0.1:8000"]}

-nN sets how many lines (after the filter), --process NAME keeps one honcho process (web, worker, socketio, schedule, redis_queue, redis_cache); lines without a honcho prefix, such as a traceback, stay with the process above them. process is null without a filter. Control characters (terminal colors) are removed. benchbar mcp uses it for benchbar_logs_tail.

benchbar repair --json

A stream: one JSON object per line on stdout, as the run goes. The human text goes to the run’s log (.benchbar/logs/<timestamp>.log).

{"event":"plan","schema_version":1,"cli_version":"0.5.0","bench":"/Users/you/frappe-bench","dry_run":false,"actions":[{"id":"build","label":"bench build","fixes":["Built assets"],"sudo":false},{"id":"hosts_entry","label":"add macdev to /etc/hosts (sudo)","fixes":["/etc/hosts entry"],"sudo":true}],"backups":"/Users/you/.local/share/benchbar/.benchbar/backups","log":"/Users/you/.local/share/benchbar/.benchbar/logs/20260926-101500.log"}
{"event":"step","action":"build","status":"running","message":"bench build"}
{"event":"step","action":"build","status":"done","message":"bench build"}
{"event":"step","action":"hosts_entry","status":"running","message":"add macdev to /etc/hosts (sudo)"}
{"event":"step","action":"hosts_entry","status":"skipped","message":"[WARN] skipped without sudo; run: printf '127.0.0.1 macdev\n' | sudo tee -a /etc/hosts"}
{"event":"done","exit_code":0,"log":"/Users/you/.local/share/benchbar/.benchbar/logs/20260926-101500.log"}
EventFields
planactions[] with id (a repair action), label, fixes (the doctor checks it fixes), sudo (needs a password: skipped without a terminal); dry_run; backups (the backup root); log
stepaction, status (running, then done, skipped or failed), message (for failed and skipped, the CLI’s [FAIL] or [WARN] line)
doneexit_code (0 when every check passes afterwards), log

repair --dry-run --json prints only the plan line and changes nothing. Without --yes (and without --dry-run) nothing is applied: the plan is printed, then done with exit code 1, because the question cannot be answered. An empty actions means nothing needs repairing.

benchbar app list --json

Added in 0.5. Every app of the bench: the lines of sites/apps.txt in order, then any git app in apps/ that is not listed. The sites come from bench --site S list-apps --format json (15 seconds per site), cached per bench; --no-sites reads only the cache, so it never needs MariaDB.

{"schema_version":1,"cli_version":"0.5.0","bench":"/Users/you/frappe-bench","profile":"v15-lts",
"sites_checked_at":"2026-09-25T10:00:00Z","sites_error":null,
"apps":[{"name":"erpnext","in_apps_txt":true,"repo":"https://github.com/frappe/erpnext","remote":"upstream",
"branch":"version-15","policy_branch":"version-15","commit":"b5f784612d5b7969b72848dda5b22f10d3a8f764",
"dirty":false,"shallow":true,"version":"15.115.0","sites":["macdev"]}]}
FieldTypeNotes
sites_checked_atstring or nullwhen the site lists were last read from bench
sites_errorstring or nullwhy the last read failed for a site (MariaDB down); its apps come from the previous read
apps[].in_apps_txtboolfalse for a git app that is only a folder (doctor warns)
apps[].repostring or nullthe URL of the remote the branch follows (else upstream, else the first), without a user name or token
apps[].remotestring or nullthat remote’s name
apps[].branchstring or nullnull on a detached HEAD or without git
apps[].policy_branchstring or nullthe branch config/apps.tsv (or the profile, for frappe) names
apps[].commitstring or nullthe full HEAD commit
apps[].dirtybooltracked files have local changes (git status --porcelain -uno)
apps[].shallowboola shallow clone (bench’s shallow_clone)
apps[].versionstring or nullfrom sites/apps.json
apps[].sitesarray of stringsthe sites that have the app installed

benchbar app update NAME --dry-run --json

Added in 0.5. The plan of an update, after git fetch (which changes only the app’s .git). --json without --dry-run is refused.

{"schema_version":1,"cli_version":"0.5.0","bench":"/Users/you/frappe-bench","app":"erpnext","remote":"upstream","branch":"version-15",
"from":"a1b2c3d...","to":"b5f7846...","commits":[{"sha":"b5f7846","subject":"fix: ..."}],"commits_total":12,
"sites":["macdev"],"skip_backup":false,
"steps":[{"name":"Back up macdev","command":"bench --site macdev backup"},{"name":"Fast forward","command":"git -C apps/erpnext merge --ff-only b5f784612d5b"}]}
FieldTypeNotes
from, tostringfull commits; equal when there is nothing to take (also when the app is ahead of its remote)
commitsarraynewest first, at most 30, sha short
commits_totalnumberall commits in from..to
sitesarray of stringsthe sites that have the app: each is backed up (unless skip_backup) and migrated
stepsarrayin order: Back up S, Fast forward, Python requirements, Node requirements, Migrate S, Build, and Restart when the bench runs; empty when there is nothing to do

A dirty tree, a detached HEAD or a diverged branch exits 1 with the reason as text.

benchbar lock check --json

Added in 0.5. The bench compared with its lockfile (benchbar.toml). Read only: no network, no database (a site’s apps come from the cache app list fills), only bench --version for the bench CLI. Exit 1 on any drift; the JSON is still printed.

{"schema_version":1,"cli_version":"0.5.0","bench":"/Users/you/frappe-bench","lock_file":"/Users/you/frappe-bench/apps/acme/benchbar.toml",
"in_sync":false,
"drift":[{"kind":"commit_behind","app":"erpnext","site":null,"expected":"b5f7846","actual":"a1b2c3d","level":"warn","fix_command":"benchbar lock apply"}],
"summary":{"ok":12,"warn":1,"fail":0}}
kindlevelMeaning
profile_mismatchwarnthe lock’s [bench] profile differs from the bench’s (a team profile’s base)
bench_version_mismatchwarnthe frappe-bench CLI version differs
app_missingfaila lock app has no folder or no apps.txt line
app_extrawarnan apps.txt app the lock does not name
repo_mismatchwarnthe app’s remote is another repo (HTTPS and SSH spellings of one repo compare equal)
branch_mismatchwarnanother branch, or a detached HEAD (actual is detached)
commit_behindwarnthe pinned commit is ahead of the checkout; lock apply fast forwards
commit_aheadwarnthe checkout has commits after the pin; lock apply leaves it
commit_divergedwarnneither contains the other; lock apply leaves it
commit_unknownwarnthe pin is not in the local history yet; lock apply fetches it
dirtywarntracked files have local changes
site_missingwarna lock site has no folder
site_app_missingwarnthe site lacks an app the lock lists for it (app and site are both set)

expected and actual are strings or null; commits are 7 characters. summary.ok counts the lock’s apps, sites and bench fields without drift. Doctor runs the same comparison as lock_drift (without the bench version) and lock_parse, both in group bench with action: null.

list --json gains benches[].lock_file: the path lock check would use for that bench (remembered from --lock, or <bench>/benchbar.toml when it exists), else null.

benchbar profile list --json

Added in 0.5. The built in profiles, then every team profile file on the lookup path (~/.config/benchbar/profiles/, then each folder of BENCHBAR_PROFILE_PATH), invalid ones included so a reader can show why.

{"schema_version":1,"cli_version":"0.5.0","profiles":[
{"name":"v15-lts","kind":"builtin","source":"builtin","file":"/Users/you/benchbar/config/release-profiles.tsv","base":null,
"label":"Frappe/ERPNext v15 LTS","frappe_branch":"version-15","valid":true,"error":null},
{"name":"acme","kind":"team","source":"user","file":"/Users/you/.config/benchbar/profiles/acme.toml","base":"v15-lts",
"label":"Acme ERP","frappe_branch":null,"valid":true,"error":null}]}
FieldTypeNotes
kindstringbuiltin or team
sourcestringbuiltin, user (~/.config/benchbar/profiles) or path (a BENCHBAR_PROFILE_PATH folder)
filestringwhere it was read from
basestring or nullthe built in profile a team profile builds on; null for built in ones and invalid files
labelstring or nullthe built in label, or a team profile’s description
frappe_branchstring or nulla team profile’s override, else the built in branch
validboolfalse when install --profile NAME would refuse it
errorstring or nullwhy: a parse error (FILE:LINE: not supported: ...), a name that shadows a built in profile, or a name hidden by an earlier file

benchbar pull --json

Added in 0.5. Unlike the commands above, pull changes things, so it streams: one JSON object per line on stdout, written as the run goes, and every human line on stderr. Use it with --yes (a gate that cannot be answered counts as no). Each line carries schema_version, cli_version and event:

{"schema_version":1,"cli_version":"0.5.0","event":"plan","source":"prod:erp.example.com","host":"prod","remote_site":"erp.example.com","remote_bench":"~/frappe-bench","from_dir":null,"bench":"/Users/you/frappe-bench","site":"erpcopy","replace":false,"backup":{"name":"20260925_020000-erp_example_com-database.sql.gz","new":false,"bytes":734003200,"age_hours":31,"encrypted":false},"encryption_key":true,"apps":[{"app":"frappe","production_version":"15.40.0","production_branch":"version-15","local_version":"15.41.0","local_branch":"version-15","status":"local newer"}],"migrate":true,"steps":["Download the backup","Restore into erpcopy","..."],"dry_run":false}
{"schema_version":1,"cli_version":"0.5.0","event":"gate","name":"apply","answer":"yes"}
{"schema_version":1,"cli_version":"0.5.0","event":"progress","file":"20260925_020000-erp_example_com-database.sql.gz","bytes":700000000,"total":734003200}
{"schema_version":1,"cli_version":"0.5.0","event":"step","n":1,"id":"download","name":"Download the backup","status":"done"}
{"schema_version":1,"cli_version":"0.5.0","event":"done","exit":0,"site":"erpcopy","url":"http://erpcopy:8000","decrypt":{"ok":12,"failed":0},"warnings":[]}
EventFieldsNotes
plansource, host, remote_site, remote_bench, from_dir, bench, site, replace, backup, encryption_key, apps, migrate, steps, dry_runonce, after the read only checks. backup.name, bytes and age_hours are null with --new-backup (that backup does not exist yet). encryption_key says whether production has one to carry over, never its value
gatename (new_backup, apply, replace), answer (yes, no)a question the run asked. new_backup is yes only when the typed (or --confirm-site) name matches
progressfile, bytes, totalafter each downloaded file; bytes counts the files so far
stepn, id, name, statusn counts from 1 in the order of plan.steps; status is done, unchanged, skipped or failed
doneexit, site, warnings, and on success url and decryptalways the last line: also after a refusal or a failure (exit 1) and after --dry-run (dry_run: true)

apps[].status is ok, missing, skipped (--skip-app), branch differs, local older or local newer. decrypt.ok and decrypt.failed count the encrypted __Auth rows that do and do not decrypt with the site’s encryption_key; failed above 0 means stored passwords must be entered again. Step ids: new_backup, download, decrypt, local_backup, restore, encryption_key, dev_safety, skip_apps, migrate, clear_cache, admin_password, hosts, cleanup, verify; a run lists only the ones it needs.

No event ever holds a password, the encryption key or a token from an app’s remote URL.

benchbar report --json

Writes the redacted diagnostics zip (to ~/Desktop, or --out DIR) and prints one line on stdout; the human text goes to stderr. The BenchBar app’s Report a Bug uses it.

{"schema_version":1,"cli_version":"0.5.5","zip":"/Users/you/Desktop/benchbar-report-20260926-101500.zip","redactions":14}
FieldTypeMeaning
zipstringabsolute path of the zip just written
redactionsnumberlines on which something was replaced (a credential, the home folder, the username, a name of this Mac); REDACTIONS.txt in the zip lists them

Exit 0 when the zip was written. --json with --print exits 1.

logs/.benchbar/state.json

Written on every transition, always by writing a temp file in the same folder and renaming it over state.json. A reader never sees half a file, but a file watcher on state.json itself breaks at the first rename: watch the logs/.benchbar/ folder instead.

{"schema_version":1,"cli_version":"0.3.0","bench":"/Users/you/frappe-bench","name":"frappe-bench","site":"macdev","label":"com.benchbar.frappe-bench","state":"running","stop_reason":null,"pid":4242,"started_at":"2026-09-23T10:00:00Z","last_exit_code":null,"web_url":"http://macdev:8000","web_ping_code":200,"updated_at":"2026-09-23T10:00:14Z","source":"runner"}

It has the core fields of status (schema_version to web_ping_code) plus:

FieldTypeNotes
updated_atstringwhen this transition was written
sourcestringrunner (the launchd runner) or cli (up, down, restart)

Transitions the runner writes:

Whenstatestop_reason
the agent starts while a stop flag existsstopped or pausedfrom the flag
honcho or env/bin/python is missingpausedbroken
the crash guard tripspausedcrash
honcho startedstartingnull
the site answered 200 (checked every 2 seconds for 4 minutes)runningnull
honcho exited 0, or the runner got SIGTERM without a stop flagstoppednull
benchdown stopped itstoppedmanual
honcho exited with an errorcrashedcrash

The CLI writes starting just before up and restart kick the agent, and stopped with manual after down. The state file is a hint for fast updates; status --json is the truth, because it also checks the processes and the site.