CLI Reference: driving Mercy SF from another program
Deutsche Fassung: cli-api.de.md
mercy-cli has a non-interactive mode: arguments in, one JSON object out. It is meant for dashboards, supervisors and scripts.
This page is the promise. The commands and field names below will not change without notice. Everything else about the CLI: the menu, its wording, the layout of its output: is free to change at any time, so please do not build on it.
Run with no arguments and the interactive menu starts exactly as before.
Every response carries "api". It is 1 today. Adding a field will not change it: a caller reading only the fields it knows stays unaffected. It changes when a field is removed or its meaning changes, so you can refuse to run against a shape you were not built for.
Logging in
Accounts created through the S&F single sign-on need nothing extra. An account that belongs to one server and was never part of the SSO does not exist as far as it is concerned, and needs --server:
mercy-cli --status --user <account> --server https://f1.sfgame.net/--server works with every command below.
It also takes a server somebody runs themselves, not only an official one:
mercy-cli --status --user <account> --server https://play.pandorasf.net/Those servers are older than the official ones and speak differently, which is handled without being asked: the address decides. There is no single sign-on there, so such an account is always a --server one, and a character name on one of them is a different account from the same name on an official server.
Output
Every command except --start prints JSON and nothing else, so --json is already the default and passing it changes nothing. It exists for callers that would rather say so than rely on a default, and for --start, which otherwise writes a human log.
Reading
List the characters on an account
mercy-cli --characters --user <account>{
"ok": true,
"characters": [
{ "character": "Hero", "server": "https://s1.sfgame.net/", "level": 345 },
{ "character": "Alt", "server": "https://s42.sfgame.net/", "level": 88 }
]
}One login covers every character on the account, across servers. level is null if that character's state could not be fetched: the entry is still listed, because knowing it exists is the point of the call.
Read one character's state
mercy-cli --status --user <account> --character <name>--character may be omitted when the account has exactly one.
{
"ok": true,
"character": "Hero",
"server": "https://s1.sfgame.net/",
"level": 345,
"class": "DemonHunter",
"race": "DarkElf",
"experience": { "current": 740300000, "next_level": 769800000 },
"silver": 725000000,
"mushrooms": 8,
"honor": 12043,
"rank": 1800,
"action": "CityGuard",
"attributes": {
"strength": { "base": 4077, "bonus": 1200 },
"dexterity": { "base": 1000, "bonus": 300 },
"intelligence": { "base": 900, "bonus": 250 },
"constitution": { "base": 3000, "bonus": 800 },
"luck": { "base": 700, "bonus": 150 }
},
"equipment": [
{
"slot": "Weapon",
"model_id": 55,
"color": 1,
"class": "DemonHunter",
"epic": true,
"legendary": false,
"armor": 0,
"min_damage": 900,
"max_damage": 1400,
"upgrades": 3,
"attributes": { "strength": 420, "constitution": 210 },
"rune": { "type": "FireDamage", "value": 60 },
"enchantment": "SwordOfVengeance",
"gem": { "type": "Strength", "value": 300 }
}
],
"arena": {
"fights_for_xp": 7,
"next_free_fight": "2026-08-06T12:04:11+02:00",
"opponents": [50099, 34316, 13668]
},
"events": ["GloriousGoldGalore", "OneBeerTwoBeerFreeBeer"],
"legendary_dungeon": { },
"hellevator": { },
"world_boss": { },
"class_world": { },
"event_windows": { },
"blacksmith": { },
"dungeons": { },
"fortress": { },
"tavern": {
"thirst_for_adventure_sec": 3600,
"beer_drunk": 2,
"mushroom_skip_allowed": true,
"quests": [
{ "length_sec": 600, "silver": 120000, "experience": 45000,
"location": "Wolves", "item": true }
]
},
"guild": {
"name": "Blvck Clover",
"rank": 12,
"honor": 90000,
"members": [
{ "name": "Hero", "level": 345, "last_online": "2026-08-06T09:12:00+02:00" }
]
},
"scrapbook_items": 1102
}Everything comes from a single state fetch, so there are no sections to request and nothing to save by asking for less.
guild is null when the character is not in one. scrapbook_items is null until the scrapbook has been read at least once. An empty equipment slot is omitted rather than listed as null.
On item names: the protocol carries a model id and a slot, not a display name, so that is what you get. It is also what the artwork is addressed by, so a dashboard can render exactly the picture the desktop app does. Inventing a name here would be a guess you could not check.
Repeat a read on an interval
mercy-cli --status --user <account> --character <name> --watch 60One login, then one object every 60 seconds until the process is stopped. Use this rather than re-running the command in a loop: each run is a fresh login, which is the most expensive way to watch a number and the most conspicuous from the server's side. The floor is 30 seconds. A failed round is printed and the loop carries on: a watcher that gives up on the first hiccup looks exactly like one where nothing is wrong.
What it will fight next, and why
mercy-cli --plan --user <account> --character <name>{
"ok": true,
"outcome": "planned",
"reason": "Next: Someone: 3 scrapbook items, 72% odds",
"candidates": [
{
"name": "Someone",
"level": 340,
"source": "pool",
"missing_items": 3,
"rank_gain": 12,
"favourite": false,
"win_chance": 0.72,
"item_score": 300.0,
"xp_score": 60.0,
"rank_score": 25.0,
"base": 385.0,
"score": 277.2
}
],
"items": {
"step": "Sell",
"command": "SellShop { ... }"
}
}The same decision the app draws: candidates scored on items, daily XP and rank, then multiplied by the odds. outcome is planned, waiting, blocked or empty, and reason says which in one sentence.
items is what the item module would send next, from the same function its run acts on: step names the step (Equip, Sell, Buy, Gem, ...) and command the request, as text. null when it has nothing to do. Working it out costs no request.
This spends no fight and no mushroom. It does crawl for candidates, so it is not free in requests: do not put it on a tight loop.
Rehearse a fight
mercy-cli --simulate --user <account> --character <name> --against <player>{ "ok": true, "against": "Someone", "against_level": 340, "win_chance": 0.63, "iterations": 1000 }Arithmetic. One lookup goes to the server to find out who they are; the fight itself is never sent. win_chance is wins over decided fights, so a simulation that cannot resolve reports on fewer than it ran.
What is carried
mercy-cli --inventory --user <account> --character <name>{ "ok": true, "carried": 12, "backpack": [ { "slot_index": 0, "model_id": 55 } ] }One list, because the game keeps one. Items have the same shape as in --status, plus slot_index.
Scrapbook
mercy-cli --scrapbook --user <account> --character <name>{ "ok": true, "unlocked": true, "owned": 1102, "monsters": 214 }unlocked is false below the level that grants a scrapbook, and owned is null there. That is not an error.
What the bot has announced
mercy-cli --notificationsPrints every level-up, event and error this installation announced, oldest first, one per line:
2026-09-13 18:14:22 Level Up! Hero reached level 231 (+1 levels)
2026-09-13 18:31:07 Event Started! World Boss event is now active for HeroNeeds neither a password nor a network: it reads a file this installation wrote. That is the point of it. The question it answers arrived as "what do I grep the log for to find the level-up entry?", from somebody who had set up a Discord webhook, levelled up and heard nothing. Nobody should have to know the answer to that.
If it prints nothing and you expected something, the usual cause is that notifications are switched off while a channel is filled in. The bot says so in its own log once every six hours, and the Notifications tab in Settings shows it in as many words.
VIP pass
mercy-cli --vip --user <account> --character <name>{
"ok": true,
"character": "Hero",
"vip_by_allowance": false,
"beer_max": 10,
"looked_up": true,
"vip_by_lookup": false,
"agree": true
}Checks the same question two ways and reports whether they agree.
vip_by_allowance is what the bot uses: the pass raises the daily beer allowance above 10, and the server sends that figure in every update, so it costs no request. vip_by_lookup reads the server's own is_vip field, which only ever arrives in another player's data: so this looks the character up by its own name to see whether a self-lookup counts.
The point is agree. The allowance reading is an inference from a threshold, and the failure it cannot see is the game changing the allowance: it would stay plausible and quietly become wrong. A lookup that agrees confirms it against something the server states directly.
looked_up is false when the server returns no player data for our own name; vip_by_lookup and agree are then null. That is a result, not an error - it means this route is closed and the allowance reading stands alone.
Recorded fights
mercy-cli --history --user <account> --character <name> --limit 50{ "ok": true, "total": 4210, "returned": 50, "battles": [] }Newest first. These are recorded by the installation that ran the bot, not fetched from the server: a process that has just started has nothing to show, and that is not a fault. total is what exists, returned is what --limit left.
The numbers worked out
mercy-cli --stats --user <account> --character <name>{ "ok": true, "view": "stats", "data": {
"runtime_hours": 41.5, "data_points": 312, "level_now": 345, "levels_gained": 3,
"arena": { "wins": 180, "losses": 22, "win_rate_pct": 89.1 },
"dungeon": { "wins": 40, "losses": 9, "win_rate_pct": 81.6 },
"scrapbook": { "wins": 95, "losses": 14, "win_rate_pct": 87.2 },
"scrapbook_items_gained": 61, "quests_completed": 220,
"xp_gained": 9120000, "xp_per_hour": 219759.0,
"silver_gained": 4410000, "silver_per_hour": 106265.0,
"mushrooms_net": -12, "mushrooms_net_per_hour": -0.0
} }What the analytics page shows, from the same record as --view analytics, so it answers from the disk too. Per hour means per hour of the bot running: an evening with the bot off is not an evening of zero silver. A rate with nothing under it is null, not zero, because no fights is not a 0% record. mushrooms_net is gained minus spent; negative means the bot spent more than came in.
Runtime recorded before 2.26.6 is too short: it was only booked when the bot was stopped cleanly, so a closed app, a crash or a restart kept the fights and lost the hours. Per-hour figures over such a history come out too high. From 2.26.6 on the runtime is booked every 30 seconds.
Acting
Run the bot
mercy-cli --start --user <account> --character <name>Runs until the process is stopped, logging as it goes. --character is required here: listing and reading state are harmless, but starting the bot plays the game, and picking the wrong character is not something you can undo.
--all runs every character of the account in this one process instead. That is one login for the account rather than one per character, which is cheaper and stops the characters invalidating each other's session.
On Ctrl-C (or SIGTERM) the current move is allowed to finish before the process exits, rather than being cut off mid-request.
Events instead of prose
mercy-cli --start --user <account> --character <name> --eventsOne JSON object per line, so a supervisor reads a line at a time:
{"at":"2026-08-06T17:22:04+02:00","api":1,"event":"arena_win","character":"Hero"}
{"at":"2026-08-06T17:22:31+02:00","api":1,"event":"quest_done","character":"Hero"}event is one of started, stopping, stopped, arena_win, arena_loss, dungeon_win, dungeon_loss, quest_done, scrapbook_win, scrapbook_loss, scrapbook_items (which carries count).
This list will grow. Treat an event you do not recognise as one to ignore, not as an error: that is the whole reason the names are spelled out rather than being whatever Rust happened to print.
Use this rather than reading the human log. That log is written for a person and is free to be reworded at any time; parsing it means your tool breaks when somebody improves a sentence.
events lists the server events running right now, spelled the way the worthwhile_events setting spells them so the two can be held against each other. That comparison is the point of it: ticking anything under "which events are worth a mushroom" REPLACES the default list of five, and a character that stops buying beer because the running event fell out of that list looks exactly like a character that is broken. Twice in one day it was mistaken for one.
legendary_dungeon is the panel the app shows for the legendary dungeon, answered from the same view rather than a second reading of the raw state. That matters for one field: the health the game sends after a killing blow can fall below zero, and while a run is healing it stops moving altogether, because the game counts a recovery of 4.17 per cent an hour instead. A caller that reads the raw number shows a negative health, which is what happened once. status says which of the five situations the run is in, and healing_percent is set only while a run is recovering.
Every panel the application draws
mercy-cli --views
mercy-cli --view <name> --user <account> --character <name>--views prints the catalogue and needs neither an account nor a password. --view answers one of them for one character.
Each name maps to the very function the desktop application draws and the fleet worker already answered. That is the whole point of the command: a number worked out a second time is a number worked out differently sooner or later, and then two windows of the same installation disagree. The negative health in the legendary dungeon was exactly that.
| name | what it answers |
|---|---|
account_overview | the row this character has in the accounts table |
achievements | achievements, and what the engine would go for next |
analytics | the recorded history of this character |
battle_history | the fights that were kept, newest first |
blacksmith | metal, arcane and how many dismantles are left today |
class_world | the class world: four class dungeons and the portal |
combat_decision | what the combat module decided last, and why |
arena | what is on offer, what is left today, and the last decision |
bot_config | the configuration this character starts from |
dungeon_runs | what each door in the legendary dungeon has been worth |
dungeons | every dungeon with its level and progress |
fight_calibration | the learned fight model as it stands for this character |
fortress | the fortress, and which building it would raise next |
gear | what each equipment slot is worth against what is in the bag |
guild | the guild: pending battles, hydra, portal, skills |
hellevator | the hellevator: floor, health, what it would do next |
legendary_dungeon | the legendary dungeon panel, health included |
live_events | which events run, and the windows the protocol gives |
mail | what is waiting in the mailbox, and what can still be claimed |
pets | the five habitats, and when exploration is free again |
scrapbook_cache | how many scanned opponents are cached for this character |
scrapbook_targets | which missing items are worth hunting |
stats | the recorded history worked out: win rates, per hour, net mushrooms |
server_start | the opening of a fresh server: which phase, and what blocks it |
tavern | quests, thirst, beer, and the shift the character is on |
underworld | the underworld, and which building it would raise next |
world_boss | the world boss and its shop |
The answer is always {"ok": true, "view": "<name>", "data": …}. What is in data is the view's own shape, which follows the application: these are the same structures its pages receive, so they can change when a page changes. Eight of them (analytics, battle_history, bot_config, combat_decision, dungeon_runs, fight_calibration, scrapbook_cache, stats) come off the disk and answer without touching the game at all.
legendary_dungeon carries one more field than its page does: why, present only while a door choice is actually on screen. It names both doors, what each scored, which one the bot would take, and the margin between them: from the same function the bot decides with, so the explanation and the move cannot disagree. A pair decided by five points is one a changed setting would flip; one decided by forty is not, and the margin is how you tell them apart.
Seven of them: fortress, underworld, tavern, guild, pets, mail, arena: carry the settings that govern them alongside the state, and five carry a would_* field naming what the module would do next and a reason when it would do nothing. That is the half that was missing: a fortress with enough wood that builds nothing and a fortress that is forbidden from building look identical in a list of numbers, and the difference is whether waiting will fix it. fortress and underworld also say, per building, whether the game's own rules block it: not the price, the rules: because a price is a matter of waiting and a rule is not.
Four of them answer null rather than an empty structure when the character has nothing of that kind: no fortress, no underworld, no pets, no guild. A character who has never unlocked a fortress and one whose fortress is empty are different facts, and a page needs to be able to say which.
dungeon_runs answers {"summary": [...], "rows": [...], "total": n}. The summary is one entry per door type: how often it was offered, how often taken, how often it lost a comparison, the mean health and keys it cost, and how many runs ended on it: with the sample count beside every average, because an average over three rows and an average over three hundred are not the same claim. An average is null rather than 0 where nothing has been measured yet: a door with no closed row is not a free door.
Collect what is waiting
mercy-cli --claim --user <account> --character <name>Calendar, daily tasks, pending unlocks. --character is required, for the same reason as --start.
Read or change the configuration
mercy-cli --config --user <account> --character <name>
mercy-cli --config --user <account> --character <name> --set auto_arena=false{ "ok": true, "settable": ["auto_arena"], "changed": [ { "key": "auto_arena", "from": true, "to": false } ] }Reading returns the whole configuration under config. The field names inside that block are not part of this promise: only the keys listed in settable are. The configuration has well over a hundred fields and grows every release; promising all of them would mean never being able to reshape the settings again.
--set accepts true/false, on/off, yes/no, 1/0, takes --character always (a switch belongs to one character, and "the only one" is a guess that is fine to read by and wrong to write by), and can be repeated. The value is read back from disk afterwards and the call fails if it did not land: reporting a change that did not happen is the one outcome you cannot recover from.
Operating
Which build is this
mercy-cli --version{ "ok": true, "version": "2.12.0", "api": 1 }The only command that needs neither a password nor a network. A supervisor has to be able to ask what it is talking to before it has credentials to talk with.
Are the learned models arriving
mercy-cli --models{
"licence_key_present": true,
"tier": "supporter",
"supporter": true,
"bundle_received": true,
"bundle_version": 3,
"models": [{ "kind": "arena", "samples": 18256 }],
"measured_settings": 1,
"next": "the models are here; each character still needs combat_calibration_enabled"
}Needs neither a password nor an account, like --version.
The models are gated on a supporter licence and delivered from the server, so there are three places the chain can break and only the last one is visible from the outside. A fleet of seventeen characters ran for weeks on the plain algorithms because there was no licence file on the machine at all: the bundle was never requested, nothing failed, and the models those very characters had produced went only to desktop installs. After that was fixed it ran another hour without applying anything, because combat_calibration_enabled defaults to false on each character.
So next names the gate that is actually shut rather than repeating the outcome. In order: no key, not a supporter, no bundle, or here but switched off per character.
The fetch is the bot's own, so what this reports is what the bot would get and not a second implementation that could disagree with it.
Update this binary
mercy-cli --update --dry-run # say what would happen
mercy-cli --update # do it{ "ok": true, "message": "Updated to 2.22.0. Restart to run it. The previous binary is at /opt/mercy/mercy-cli.old." }Needs neither a password nor an account, like --version: somebody updating a node has no reason to hand over credentials to do it. mercy-node takes the same two flags.
Four things are worth knowing before putting this in a script.
Nothing happens unless you run it. There is no timer and no check at startup. A binary on a machine nobody is watching that swaps itself out is not a convenience.
The download is verified before anything is written. The bytes are checked in memory against the same minisign key the desktop app trusts, and a download that does not verify is discarded without the running binary being touched. A manifest is a file on a web server; if serving a different one were enough to replace the binary on every node, this would be a remote shell rather than an updater.
The old binary is kept. On Linux the running process holds its inode, so the swap is a rename and the process carries on with the old code until it is restarted. On Windows the old file is moved to <name>.old and cleared away by the next --update.
A platform with no build is told so. When a release exists but was not built for this machine, the answer is an error naming the missing key, not a different platform's binary:
{ "ok": false, "error": "2.22.0 exists but there is no mercy-cli-macos-arm64 in it. Nothing was changed." }Is it reachable
mercy-cli --health --user <account> --character <name>{ "ok": true, "character": "Hero", "level": 345, "round_trip_ms": 412 }Log in, fetch, report, exit. Deliberately small: a health check that returns a full character sheet invites parsing it, and then the check becomes an interface of its own.
Deadlines, retries, proxy, log file
mercy-cli --status --user <account> --character <name> \
--timeout 60 --retry 3 --proxy socks5://127.0.0.1:1080 --log-file mercy.log--timeout <seconds>ends the call after that long, login included, with{ "ok": false, "error": "timed out after 60s" }and exit code124. A dashboard calling this on a timer no longer has one call hang and hold up the next. Not with--startor--watch, which run until stopped.--retry <n>logs in again up tontimes (at most 5), waiting 2, 4, 8, 16 and 32 seconds, but only when the network failed. A wrong password or a refusal from the server is never retried: asking again with a bad password looks like guessing.--proxy <url>sends this run through a proxy (http://,https://,socks5://,socks5h://, credentials in the URL if needed). It replaces the proxy saved for the account for this run only.--log-file <path>appends the log to a file instead of stderr. JSON still goes to stdout.
--node: the same worker, another door
mercy-cli --nodeRuns this binary as the fleet worker: NDJSON in on stdin, NDJSON out on stdout, logs on stderr, until stdin closes. That is exactly what mercy-node does, and it is the same code: one file in the library with two doors in front of it.
The reason is distribution and nothing else. mercy-cli is built, signed and shipped for five platforms; mercy-node for two. A dashboard that needs the worker therefore did not run on Windows or macOS: not for want of code, but for want of a build.
Not something to type by hand. Anyone who does is sitting in front of a program waiting for a line of JSON and saying nothing otherwise.
--update renews this binary and not the node: somebody calling mercy-cli --node --update wants the CLI current, not replaced by a different program.
The password
There is no --password flag, on purpose. Command line arguments are readable by every other process on the machine: ps, /proc, Task Manager: so a dashboard running this for several people would be handing all of their game passwords to anyone with a shell on that box.
Two ways instead:
MERCY_SF_PASSWORD='…' mercy-cli --status --user someoneprintf '%s' "$password" | mercy-cli --status --user someone --password-stdin--password-stdin reads the first line of standard input, which keeps the secret out of the environment as well.
Exit codes
| Code | Meaning |
|---|---|
0 | Success, and for --help |
1 | It ran and failed: the reason is in error, and ok is false |
2 | The call was wrong: unknown option, missing value, no action. On stderr |
124 | --timeout ran out. The same code the timeout command uses |
The difference between 1 and 2 matters to a supervisor: 2 will fail the same way every time and needs a person, 1 may well work on the next attempt.
Errors
Failures are JSON too, so one parser handles both:
{ "ok": false, "error": "no character called 'Typo' on this account" }ok is present on every response. Check it before anything else.
Common causes:
| Message | What to do |
|---|---|
--user is required | --user is missing (every action except --version) |
--start needs --character (or --all) | Name the target; it will not be guessed |
this account has N characters: name one with --character | Several exist, pick one |
no character called '…' on this account | Typo; --characters lists the valid names |
no password: set MERCY_SF_PASSWORD or pass --password-stdin | Choose one of the two password routes |
login failed: … | Check the credentials or the server; non-SSO accounts need --server |
'…' is not settable from here | Not on the allow-list; --config without --set prints settable |
'…' did not save: it still reads back unchanged | The change did not land; check write permissions |
Recipes
Collect on every account once a day
for account in alpha beta gamma; do
MERCY_SF_PASSWORD="$(pass mercy/$account)" \
mercy-cli --claim --user "$account" --character Hero || echo "failed: $account"
doneKeep a dashboard tile current
MERCY_SF_PASSWORD='…' mercy-cli --status --user someone --character Hero --watch 60One login, one object a minute. Do not re-run this from cron every minute instead: that is a fresh login each time.
Record events to a file
MERCY_SF_PASSWORD='…' mercy-cli --start --user someone --character Hero --events \
>> events.ndjsonCheck what you are talking to before logging in
api=$(mercy-cli --version | jq -r .api)
[ "$api" = "1" ] || { echo "unknown contract version: $api"; exit 1; }As a systemd service
[Service]
Environment=MERCY_SF_PASSWORD=…
ExecStart=/usr/local/bin/mercy-cli --start --user someone --all --events
Restart=on-failure
KillSignal=SIGTERMSIGTERM lets the current move finish. Please do not SIGKILL it.
What this does not do yet
Starting several characters and stopping them individually (--all starts them all and stops them all together), a control channel into a running process, and changing anything beyond the module switches.
If you need one of those, say which: it is easier to build the right thing than to guess and have it go unused.